--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { TreeItem, TreeItemStatus } from '../tree/tree';
+import { FileTreeData } from '../file-tree/file-tree-data';
+import { FileTree } from '../file-tree/file-tree';
+import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, CardContent, Card, Button } from '@material-ui/core';
+import { CustomizeTableIcon } from '../icon/icon';
+
+export interface CollectionPanelFilesProps {
+ items: Array<TreeItem<FileTreeData>>;
+ onUploadDataClick: () => void;
+ onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+ onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>) => void;
+ onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+ onCollapseToggle: (id: string, status: TreeItemStatus) => void;
+}
+
+type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+ root: {
+ paddingBottom: theme.spacing.unit
+ },
+ cardSubheader: {
+ paddingTop: 0,
+ paddingBottom: 0
+ },
+ nameHeader: {
+ marginLeft: '75px'
+ },
+ fileSizeHeader: {
+ marginRight: '65px'
+ }
+});
+
+export const CollectionPanelFiles = withStyles(styles)(
+ ({ onItemMenuOpen, onOptionsMenuOpen, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+ <Card className={classes.root}>
+ <CardHeader
+ title="Files"
+ action={
+ <Button
+ variant='raised'
+ color='primary'
+ size='small'>
+ Upload data
+ </Button>
+ } />
+ <CardHeader
+ className={classes.cardSubheader}
+ action={
+ <IconButton onClick={onOptionsMenuOpen}>
+ <CustomizeTableIcon />
+ </IconButton>
+ } />
+ <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={onItemMenuOpen} {...treeProps} />
+ </Card>);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface FileTreeData {
+ name: string;
+ type: string;
+ size?: number;
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { TreeItem } from "../tree/tree";
+import { ProjectIcon, MoreOptionsIcon, DefaultIcon, CollectionIcon } from "../icon/icon";
+import { Typography, IconButton, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core";
+import { formatFileSize } from "../../common/formatters";
+import { ListItemTextIcon } from "../list-item-text-icon/list-item-text-icon";
+import { FileTreeData } from "./file-tree-data";
+
+type CssRules = "root" | "spacer" | "sizeInfo" | "button";
+
+const fileTreeItemStyle: StyleRulesCallback<CssRules> = theme => ({
+ root: {
+ display: "flex",
+ alignItems: "center",
+ paddingRight: `${theme.spacing.unit * 1.5}px`
+ },
+ spacer: {
+ flex: "1"
+ },
+ sizeInfo: {
+ width: `${theme.spacing.unit * 8}px`
+ },
+ button: {
+ width: theme.spacing.unit * 3,
+ height: theme.spacing.unit * 3,
+ marginRight: theme.spacing.unit
+ }
+});
+
+export interface FileTreeItemProps {
+ item: TreeItem<FileTreeData>;
+ onMoreClick: (event: React.MouseEvent<any>, item: TreeItem<FileTreeData>) => void;
+}
+export const FileTreeItem = withStyles(fileTreeItemStyle)(
+ class extends React.Component<FileTreeItemProps & WithStyles<CssRules>> {
+ render() {
+ const { classes, item } = this.props;
+ return <div className={classes.root}>
+ <ListItemTextIcon
+ icon={getIcon(item)}
+ name={item.data.name} />
+ <div className={classes.spacer} />
+ <Typography
+ className={classes.sizeInfo}
+ variant="caption">{formatFileSize(item.data.size)}</Typography>
+ <IconButton
+ className={classes.button}
+ onClick={this.handleClick}>
+ <MoreOptionsIcon />
+ </IconButton>
+ </div >;
+ }
+
+ handleClick = (event: React.MouseEvent<any>) => {
+ this.props.onMoreClick(event, this.props.item);
+ }
+ });
+
+const getIcon = (item: TreeItem<FileTreeData>) => {
+ switch(item.data.type){
+ case 'directory':
+ return ProjectIcon;
+ case 'file':
+ return CollectionIcon;
+ default:
+ return DefaultIcon;
+ }
+};
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Tree, TreeItem, TreeItemStatus } from "../tree/tree";
+import { FileTreeData } from "./file-tree-data";
+import { FileTreeItem } from "./file-tree-item";
+
+export interface FileTreeProps {
+ items: Array<TreeItem<FileTreeData>>;
+ onMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+ onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+ onCollapseToggle: (id: string, status: TreeItemStatus) => void;
+}
+
+export class FileTree extends React.Component<FileTreeProps> {
+ render() {
+ return <Tree
+ showSelection={true}
+ items={this.props.items}
+ disableRipple={true}
+ render={this.renderItem}
+ onContextMenu={this.handleContextMenu}
+ toggleItemActive={this.handleToggleActive}
+ toggleItemOpen={this.handleToggle}
+ onSelectionChange={this.handleSelectionChange} />;
+ }
+
+ handleContextMenu = (event: React.MouseEvent<any>, item: TreeItem<FileTreeData>) => {
+ event.stopPropagation();
+ this.props.onMenuOpen(event, item);
+ }
+
+ handleToggle = (id: string, status: TreeItemStatus) => {
+ this.props.onCollapseToggle(id, status);
+ }
+
+ handleToggleActive = () => { return; };
+
+ handleSelectionChange = (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => {
+ event.stopPropagation();
+ this.props.onSelectionToggle(event, item);
+ }
+
+ renderItem = (item: TreeItem<FileTreeData>) =>
+ <FileTreeItem
+ item={item}
+ onMoreClick={this.handleContextMenu} />
+
+}
color: theme.palette.primary.main,
},
hasMargin: {
- marginLeft: '18px',
+ marginLeft: `${theme.spacing.unit}px`,
},
});
toggableIconContainer: {
color: theme.palette.grey["700"],
height: '14px',
- position: 'absolute'
+ width: '14px'
},
toggableIcon: {
fontSize: '14px'
import { Tree, TreeItem } from './tree';
import { ProjectResource } from '../../models/project';
import { mockProjectResource } from '../../models/test-utils';
+import { Checkbox } from '@material-ui/core';
Enzyme.configure({ adapter: new Adapter() });
items={[project]} />);
expect(wrapper.find('i')).toHaveLength(1);
});
+
+ it("should render checkbox", () => {
+ const project: TreeItem<ProjectResource> = {
+ data: mockProjectResource(),
+ id: "3",
+ open: true,
+ active: true,
+ status: 1,
+ };
+ const wrapper = mount(<Tree
+ showSelection={true}
+ render={() => <div />}
+ toggleItemOpen={jest.fn()}
+ toggleItemActive={jest.fn()}
+ onContextMenu={jest.fn()}
+ items={[project]} />);
+ expect(wrapper.find(Checkbox)).toHaveLength(1);
+ });
+
+ it("call onSelectionChanged with associated item", () => {
+ const project: TreeItem<ProjectResource> = {
+ data: mockProjectResource(),
+ id: "3",
+ open: true,
+ active: true,
+ status: 1,
+ };
+ const spy = jest.fn();
+ const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
+ const wrapper = mount(<Tree
+ showSelection={true}
+ render={() => <div />}
+ toggleItemOpen={jest.fn()}
+ toggleItemActive={jest.fn()}
+ onContextMenu={jest.fn()}
+ onSelectionChange={onSelectionChanged}
+ items={[project]} />);
+ wrapper.find(Checkbox).prop('onClick')();
+ expect(spy).toHaveBeenLastCalledWith({
+ data: mockProjectResource(),
+ id: "3",
+ open: true,
+ active: true,
+ status: 1,
+ });
+ });
+
});
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
-import { List, ListItem, ListItemIcon, Collapse } from "@material-ui/core";
+import { List, ListItem, ListItemIcon, Collapse, Checkbox } from "@material-ui/core";
import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
import { ReactElement } from "react";
import CircularProgress from '@material-ui/core/CircularProgress';
import { ArvadosTheme } from '../../common/custom-theme';
import { SidePanelRightArrowIcon } from '../icon/icon';
-type CssRules = 'list' | 'active' | 'loader' | 'toggableIconContainer' | 'iconClose' | 'iconOpen' | 'toggableIcon';
+type CssRules = 'list'
+ | 'listItem'
+ | 'active'
+ | 'loader'
+ | 'toggableIconContainer'
+ | 'iconClose'
+ | 'renderContainer'
+ | 'iconOpen'
+ | 'toggableIcon'
+ | 'checkbox';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
list: {
padding: '3px 0px'
},
+ listItem: {
+ padding: '3px 0px',
+ },
loader: {
position: 'absolute',
transform: 'translate(0px)',
toggableIconContainer: {
color: theme.palette.grey["700"],
height: '14px',
- position: 'absolute'
+ width: '14px',
},
toggableIcon: {
fontSize: '14px'
},
+ renderContainer: {
+ flex: 1
+ },
active: {
color: theme.palette.primary.main,
},
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`,
+ color: theme.palette.grey["500"]
}
});
id: string;
open: boolean;
active: boolean;
+ selected?: boolean;
status: TreeItemStatus;
items?: Array<TreeItem<T>>;
}
toggleItemActive: (id: string, status: TreeItemStatus) => void;
level?: number;
onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+ showSelection?: boolean;
+ onSelectionChange?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+ disableRipple?: boolean;
}
export const Tree = withStyles(styles)(
class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
render(): ReactElement<any> {
const level = this.props.level ? this.props.level : 0;
- const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu } = this.props;
- const { list, loader, toggableIconContainer } = classes;
+ const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu, disableRipple } = this.props;
+ const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
return <List component="div" className={list}>
{items && items.map((it: TreeItem<T>, idx: number) =>
<div key={`item/${level}/${idx}`}>
- <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }}
+ <ListItem button className={listItem} style={{ paddingLeft: (level + 1) * 20 }}
+ disableRipple={disableRipple}
onClick={() => toggleItemActive(it.id, it.status)}
onContextMenu={this.handleRowContextMenu(it)}>
{it.status === TreeItemStatus.PENDING ?
{it.status !== TreeItemStatus.INITIAL && it.items && it.items.length === 0 ? <span /> : <SidePanelRightArrowIcon />}
</ListItemIcon>
</i>
- {render(it, level)}
+ {this.props.showSelection &&
+ <Checkbox
+ checked={it.selected}
+ className={classes.checkbox}
+ color="primary"
+ onClick={this.handleCheckboxChange(it)} />}
+ <div className={renderContainer}>
+ {render(it, level)}
+ </div>
</ListItem>
{it.items && it.items.length > 0 &&
<Collapse in={it.open} timeout="auto" unmountOnExit>
<Tree
+ showSelection={this.props.showSelection}
items={it.items}
render={render}
+ disableRipple={disableRipple}
toggleItemOpen={toggleItemOpen}
toggleItemActive={toggleItemActive}
level={level + 1}
- onContextMenu={onContextMenu} />
+ onContextMenu={onContextMenu}
+ onSelectionChange={this.props.onSelectionChange} />
</Collapse>}
</div>)}
</List>;
handleRowContextMenu = (item: TreeItem<T>) =>
(event: React.MouseEvent<HTMLElement>) =>
this.props.onContextMenu(event, item)
+
+ handleCheckboxChange = (item: TreeItem<T>) => {
+ const { onSelectionChange } = this.props;
+ return onSelectionChange
+ ? (event: React.MouseEvent<HTMLElement>) => {
+ onSelectionChange(event, item);
+ }
+ : undefined;
+ }
}
);
import { projectActionSet } from "./views-components/context-menu/action-sets/project-action-set";
import { resourceActionSet } from './views-components/context-menu/action-sets/resource-action-set';
import { favoriteActionSet } from "./views-components/context-menu/action-sets/favorite-action-set";
+import { collectionFilesActionSet } from './views-components/context-menu/action-sets/collection-files-action-set';
+import { collectionFilesItemActionSet } from './views-components/context-menu/action-sets/collection-files-item-action-set';
import { collectionActionSet } from './views-components/context-menu/action-sets/collection-action-set';
addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES_ITEM, collectionFilesItemActionSet);
addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet);
fetchConfig()
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree } from './tree';
+
+export type CollectionFilesTree = Tree<CollectionDirectory | CollectionFile>;
+
+export enum CollectionFileType {
+ DIRECTORY = 'directory',
+ FILE = 'file'
+}
+
+export interface CollectionDirectory {
+ parentId: string;
+ id: string;
+ name: string;
+ type: CollectionFileType.DIRECTORY;
+}
+
+export interface CollectionFile {
+ parentId: string;
+ id: string;
+ name: string;
+ size: number;
+ type: CollectionFileType.FILE;
+}
+
+export const createCollectionDirectory = (data: Partial<CollectionDirectory>): CollectionDirectory => ({
+ id: '',
+ name: '',
+ parentId: '',
+ type: CollectionFileType.DIRECTORY,
+ ...data
+});
+
+export const createCollectionFile = (data: Partial<CollectionFile>): CollectionFile => ({
+ id: '',
+ name: '',
+ parentId: '',
+ size: 0,
+ type: CollectionFileType.FILE,
+ ...data
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type KeepManifest = KeepManifestStream[];
+
+export interface KeepManifestStream {
+ name: string;
+ locators: string[];
+ files: Array<KeepManifestStreamFile>;
+}
+
+export interface KeepManifestStreamFile {
+ name: string;
+ position: string;
+ size: number;
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Tree from './tree';
+
+describe('Tree', () => {
+ let tree: Tree.Tree<string>;
+
+ beforeEach(() => {
+ tree = Tree.createTree();
+ });
+
+ it('sets new node', () => {
+ const newTree = Tree.setNode({ children: [], id: 'Node 1', parent: '', value: 'Value 1' })(tree);
+ expect(Tree.getNode('Node 1')(newTree)).toEqual({ children: [], id: 'Node 1', parent: '', value: 'Value 1' });
+ });
+
+ it('adds new node reference to parent children', () => {
+ const [newTree] = [tree]
+ .map(Tree.setNode({ children: [], id: 'Node 1', parent: '', value: 'Value 1' }))
+ .map(Tree.setNode({ children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 2' }));
+
+ expect(Tree.getNode('Node 1')(newTree)).toEqual({ children: ['Node 2'], id: 'Node 1', parent: '', value: 'Value 1' });
+ });
+
+ it('gets node ancestors', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 3', parent: 'Node 2', value: 'Value 1' }
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ expect(Tree.getNodeAncestors('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+ });
+
+ it('gets node descendants', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+ { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ expect(Tree.getNodeDescendants('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+ });
+
+ it('gets root descendants', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+ { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ expect(Tree.getNodeDescendants('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+ });
+
+ it('gets node children', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+ { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ expect(Tree.getNodeChildren('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+ });
+
+ it('gets root children', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+ { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+ { children: [], id: 'Node 3', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ expect(Tree.getNodeChildren('')(newTree)).toEqual(['Node 1', 'Node 3']);
+ });
+
+ it('maps tree', () => {
+ const newTree = [
+ { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+ { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 2' },
+ ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+ const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
+ expect(Tree.getNode('Node 2')(mappedTree)).toEqual({ children: [], id: 'Node 2', parent: 'Node 1', value: 2 }, );
+ });
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type Tree<T> = Record<string, TreeNode<T>>;
+
+export const TREE_ROOT_ID = '';
+
+export interface TreeNode<T> {
+ children: string[];
+ value: T;
+ id: string;
+ parent: string;
+}
+
+export const createTree = <T>(): Tree<T> => ({});
+
+export const getNode = (id: string) => <T>(tree: Tree<T>): TreeNode<T> | undefined => tree[id];
+
+export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
+ const [newTree] = [tree]
+ .map(tree => getNode(node.id)(tree) === node
+ ? tree
+ : {...tree, [node.id]: node})
+ .map(addChild(node.parent, node.id));
+ return newTree;
+};
+
+export const getNodeValue = (id: string) => <T>(tree: Tree<T>) => {
+ const node = getNode(id)(tree);
+ return node ? node.value : undefined;
+};
+
+export const setNodeValue = (id: string) => <T>(value: T) => (tree: Tree<T>) => {
+ const node = getNode(id)(tree);
+ return node
+ ? setNode(mapNodeValue(() => value)(node))(tree)
+ : tree;
+};
+
+export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (tree: Tree<T>) => {
+ const node = getNode(id)(tree);
+ return node
+ ? setNode(mapNodeValue(mapFn)(node))(tree)
+ : tree;
+};
+
+export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
+ getNodeDescendants('')(tree)
+ .map(id => getNode(id)(tree))
+ .map(mapNodeValue(mapFn))
+ .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
+
+export const mapTree = <T, R>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+ getNodeDescendants('')(tree)
+ .map(id => getNode(id)(tree))
+ .map(mapFn)
+ .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
+
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>): string[] => {
+ const node = getNode(id)(tree);
+ return node && node.parent
+ ? [...getNodeAncestors(node.parent)(tree), node.parent]
+ : [];
+};
+
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+ const node = getNode(id)(tree);
+ const children = node ? node.children :
+ id === TREE_ROOT_ID
+ ? getRootNodeChildren(tree)
+ : [];
+
+ return children
+ .concat(limit < 1
+ ? []
+ : children
+ .map(id => getNodeDescendants(id, limit - 1)(tree))
+ .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
+};
+
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
+ getNodeDescendants(id, 0)(tree);
+
+const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
+ ({ ...node, value: mapFn(node.value) });
+
+const getRootNodeChildren = <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> => {
+ 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;
+};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionService } from "../collection-service/collection-service";
+import { parseKeepManifestText } 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
+ )
+ )
+ );
+ }
+
+}
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { parseKeepManifestText } from "./collection-manifest-parser";
+import { mapManifestToFiles, mapManifestToDirectories } 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([{
+ parentId: '',
+ id: '/a',
+ name: 'a',
+ size: 0,
+ type: 'file'
+ }, {
+ parentId: '',
+ id: '/b',
+ name: 'b',
+ size: 0,
+ type: 'file'
+ }, {
+ parentId: '',
+ id: '/output.txt',
+ name: 'output.txt',
+ size: 33,
+ type: 'file'
+ }, {
+ parentId: '/c',
+ id: '/c/d',
+ name: 'd',
+ size: 0,
+ type: 'file'
+ },]);
+});
+
+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([{
+ parentId: "",
+ id: '/c',
+ name: 'c',
+ type: 'directory'
+ }, {
+ parentId: '/c',
+ id: '/c/user',
+ name: 'user',
+ type: 'directory'
+ }, {
+ parentId: '/c/user',
+ id: '/c/user/results',
+ name: 'results',
+ type: 'directory'
+ },]);
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { uniqBy } from 'lodash';
+import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "../../models/keep-manifest";
+import { TreeNode, setNode, createTree } from '../../models/tree';
+import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile } from '../../models/collection-file';
+
+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.parentId,
+ value: file
+});
+
+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({
+ parentId: index === 0 ? '' : joinPathComponents(components, index),
+ id: joinPathComponents(components, index + 1),
+ name: component,
+ });
+
+const joinPathComponents = (components: string[], index: number) =>
+ `/${components.slice(0, index).join('/')}`;
+
+const mapStreamDirectory = (stream: KeepManifestStream): CollectionDirectory =>
+ createCollectionDirectory({
+ parentId: '',
+ id: stream.name,
+ name: stream.name,
+ });
+
+const mapStreamFile = (stream: KeepManifestStream) =>
+ (file: KeepManifestStreamFile): CollectionFile =>
+ createCollectionFile({
+ parentId: stream.name,
+ id: `${stream.name}/${file.name}`,
+ name: file.name,
+ size: file.size,
+ });
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { parseKeepManifestText, parseKeepManifestStream } 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},
+ ]);
+ });
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { KeepManifestStream, KeepManifestStreamFile } from "../../models/keep-manifest";
+
+/**
+ * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html)
+ */
+export const parseKeepManifestText = (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)
+ };
+};
+
+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) };
+};
\ No newline at end of file
import { LinkService } from "./link-service/link-service";
import { FavoriteService } from "./favorite-service/favorite-service";
import { AxiosInstance } from "axios";
-import { CommonResourceService } from "../common/api/common-resource-service";
-import { CollectionResource } from "../models/collection";
-import { Resource } from "../models/resource";
import { CollectionService } from "./collection-service/collection-service";
import Axios from "axios";
+import { CollectionFilesService } from "./collection-files-service/collection-files-service";
export interface ServiceRepository {
apiClient: AxiosInstance;
projectService: ProjectService;
linkService: LinkService;
favoriteService: FavoriteService;
- collectionService: CommonResourceService<Resource>;
+ collectionService: CollectionService;
+ collectionFilesService: CollectionFilesService;
}
export const createServices = (baseUrl: string): ServiceRepository => {
const linkService = new LinkService(apiClient);
const favoriteService = new FavoriteService(linkService, groupsService);
const collectionService = new CollectionService(apiClient);
+ const collectionFilesService = new CollectionFilesService(collectionService);
return {
apiClient,
projectService,
linkService,
favoriteService,
- collectionService
+ collectionService,
+ collectionFilesService
};
};
import { Dispatch } from "redux";
import { ResourceKind } from "../../models/resource";
import { CollectionResource } from "../../models/collection";
+import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions";
+import { createTree } from "../../models/tree";
import { RootState } from "../store";
import { ServiceRepository } from "../../services/services";
export const loadCollection = (uuid: string, kind: ResourceKind) =>
(dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid, kind }));
+ dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() }));
return services.collectionService
.get(uuid)
.then(item => {
- dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: item as CollectionResource }));
+ dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item }));
+ return services.collectionFilesService.getFiles(item.uuid);
+ })
+ .then(files => {
+ dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(files));
});
};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { CollectionFilesTree } from "../../../models/collection-file";
+
+export const collectionPanelFilesAction = unionize({
+ SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
+ TOGGLE_COLLECTION_FILE_COLLAPSE: ofType<{ id: string }>(),
+ TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(),
+ SELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+ UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+}, { tag: 'type', value: 'payload' });
+
+export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionPanelFilesReducer } from "./collection-panel-files-reducer";
+import { collectionPanelFilesAction } from "./collection-panel-files-actions";
+import { CollectionFile, CollectionDirectory, createCollectionFile, createCollectionDirectory } from "../../../models/collection-file";
+import { createTree, setNode, getNodeValue, mapTreeValues, Tree } from "../../../models/tree";
+import { CollectionPanelFile, CollectionPanelDirectory } from "./collection-panel-files-state";
+
+describe('CollectionPanelFilesReducer', () => {
+
+ const files: Array<CollectionFile | CollectionDirectory> = [
+ createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
+ createCollectionDirectory({ id: 'Directory 2', name: 'Directory 2', parentId: 'Directory 1' }),
+ createCollectionDirectory({ id: 'Directory 3', name: 'Directory 3', parentId: '' }),
+ createCollectionDirectory({ id: 'Directory 4', name: 'Directory 4', parentId: 'Directory 3' }),
+ createCollectionFile({ id: 'file1.txt', name: 'file1.txt', parentId: 'Directory 2' }),
+ createCollectionFile({ id: 'file2.txt', name: 'file2.txt', parentId: 'Directory 2' }),
+ createCollectionFile({ id: 'file3.txt', name: 'file3.txt', parentId: 'Directory 3' }),
+ createCollectionFile({ id: 'file4.txt', name: 'file4.txt', parentId: 'Directory 3' }),
+ createCollectionFile({ id: 'file5.txt', name: 'file5.txt', parentId: 'Directory 4' }),
+ ];
+
+ const collectionFilesTree = files.reduce((tree, file) => setNode({
+ children: [],
+ id: file.id,
+ parent: file.parentId,
+ value: file
+ })(tree), createTree<CollectionFile | CollectionDirectory>());
+
+ const collectionPanelFilesTree = collectionPanelFilesReducer(
+ createTree<CollectionPanelFile | CollectionPanelDirectory>(),
+ collectionPanelFilesAction.SET_COLLECTION_FILES(collectionFilesTree));
+
+ it('SET_COLLECTION_FILES', () => {
+ expect(getNodeValue('Directory 1')(collectionPanelFilesTree)).toEqual({
+ ...createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
+ collapsed: true,
+ selected: false
+ });
+ });
+
+ it('TOGGLE_COLLECTION_FILE_COLLAPSE', () => {
+ const newTree = collectionPanelFilesReducer(
+ collectionPanelFilesTree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id: 'Directory 3' }));
+
+ const value = getNodeValue('Directory 3')(newTree)! as CollectionPanelDirectory;
+ expect(value.collapsed).toBe(false);
+ });
+
+ it('TOGGLE_COLLECTION_FILE_SELECTION', () => {
+ const newTree = collectionPanelFilesReducer(
+ collectionPanelFilesTree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 3' }));
+
+ const value = getNodeValue('Directory 3')(newTree);
+ expect(value!.selected).toBe(true);
+ });
+
+ it('TOGGLE_COLLECTION_FILE_SELECTION ancestors', () => {
+ const newTree = collectionPanelFilesReducer(
+ collectionPanelFilesTree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+
+ const value = getNodeValue('Directory 1')(newTree);
+ expect(value!.selected).toBe(true);
+ });
+
+ it('TOGGLE_COLLECTION_FILE_SELECTION descendants', () => {
+ const newTree = collectionPanelFilesReducer(
+ collectionPanelFilesTree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+ expect(getNodeValue('file1.txt')(newTree)!.selected).toBe(true);
+ expect(getNodeValue('file2.txt')(newTree)!.selected).toBe(true);
+ });
+
+ it('TOGGLE_COLLECTION_FILE_SELECTION unselect ancestors', () => {
+ const [newTree] = [collectionPanelFilesTree]
+ .map(tree => collectionPanelFilesReducer(
+ tree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' })))
+ .map(tree => collectionPanelFilesReducer(
+ tree,
+ collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'file1.txt' })));
+
+ expect(getNodeValue('Directory 2')(newTree)!.selected).toBe(false);
+ });
+
+ it('SELECT_ALL_COLLECTION_FILES', () => {
+ const newTree = collectionPanelFilesReducer(
+ collectionPanelFilesTree,
+ collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+
+ mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+ expect(v.selected).toEqual(true);
+ return v;
+ })(newTree);
+ });
+
+ it('SELECT_ALL_COLLECTION_FILES', () => {
+ const [newTree] = [collectionPanelFilesTree]
+ .map(tree => collectionPanelFilesReducer(
+ tree,
+ collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES()))
+ .map(tree => collectionPanelFilesReducer(
+ tree,
+ collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()));
+
+ mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+ expect(v.selected).toEqual(false);
+ return v;
+ })(newTree);
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile } from "./collection-panel-files-state";
+import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestors, getNodeDescendants, setNodeValueWith, mapTree } from "../../../models/tree";
+import { CollectionFileType } from "../../../models/collection-file";
+
+export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
+ return collectionPanelFilesAction.match(action, {
+ SET_COLLECTION_FILES: files =>
+ mapTree(mapCollectionFileToCollectionPanelFile)(files),
+
+ TOGGLE_COLLECTION_FILE_COLLAPSE: data =>
+ toggleCollapse(data.id)(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),
+
+ UNSELECT_ALL_COLLECTION_FILES: () =>
+ mapTreeValues(v => ({ ...v, selected: false }))(state),
+
+ default: () => state
+ }) as CollectionPanelFilesState;
+};
+
+const toggleCollapse = (id: string) => (tree: CollectionPanelFilesState) =>
+ setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) =>
+ v.type === CollectionFileType.DIRECTORY
+ ? { ...v, collapsed: !v.collapsed }
+ : v)(id)(tree);
+
+
+const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
+ setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) => ({ ...v, selected: !v.selected }))(id)(tree);
+
+
+const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
+ const node = getNode(id)(tree);
+ if (node && node.value.type === CollectionFileType.DIRECTORY) {
+ return getNodeDescendants(id)(tree)
+ .reduce((newTree, id) =>
+ setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
+ }
+ return tree;
+};
+
+const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
+ const ancestors = getNodeAncestors(id)(tree).reverse();
+ return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
+};
+
+const toggleParentNode = (id: string) => (tree: CollectionPanelFilesState) => {
+ const node = getNode(id)(tree);
+ if (node) {
+ const parentNode = getNode(node.id)(tree);
+ if (parentNode) {
+ const selected = parentNode.children
+ .map(id => getNode(id)(tree))
+ .every(node => node !== undefined && node.value.selected);
+ return setNodeValueWith(v => ({ ...v, selected }))(parentNode.id)(tree);
+ }
+ return setNode(node)(tree);
+ }
+ return tree;
+};
+
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionFile, CollectionDirectory, CollectionFileType } from '../../../models/collection-file';
+import { Tree, TreeNode } from '../../../models/tree';
+
+export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
+
+export interface CollectionPanelDirectory extends CollectionDirectory {
+ collapsed: boolean;
+ selected: boolean;
+}
+
+export interface CollectionPanelFile extends CollectionFile {
+ selected: boolean;
+}
+
+export const mapCollectionFileToCollectionPanelFile = (node: TreeNode<CollectionDirectory | CollectionFile>): TreeNode<CollectionPanelDirectory | CollectionPanelFile> => {
+ return {
+ ...node,
+ value: node.value.type === CollectionFileType.DIRECTORY
+ ? { ...node.value, selected: false, collapsed: true }
+ : { ...node.value, selected: false }
+ };
+};
\ No newline at end of file
// SPDX-License-Identifier: AGPL-3.0
import { unionize, ofType, UnionOf } from "unionize";
-import { CommonResourceService } from "../../common/api/common-resource-service";
import { Dispatch } from "redux";
import { Resource, ResourceKind } from "../../models/resource";
import { RootState } from "../store";
export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
export const loadDetails = (uuid: string, kind: ResourceKind) =>
- (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(detailsPanelActions.LOAD_DETAILS({ uuid, kind }));
- getService(services, kind)
- .get(uuid)
- .then(project => {
- dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item: project }));
- });
+ const item = await getService(services, kind).get(uuid);
+ dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item }));
};
const getService = (services: ServiceRepository, kind: ResourceKind) => {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+
+export const dialogActions = unionize({
+ OPEN_DIALOG: ofType<{ id: string, data: any }>(),
+ CLOSE_DIALOG: ofType<{ id: string }>()
+}, {
+ tag: 'type',
+ value: 'payload'
+ });
+
+export type DialogAction = UnionOf<typeof dialogActions>;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogReducer } from "./dialog-reducer";
+import { dialogActions } from "./dialog-actions";
+
+describe('DialogReducer', () => {
+ it('OPEN_DIALOG', () => {
+ const id = 'test id';
+ const data = 'test data';
+ const state = dialogReducer({}, dialogActions.OPEN_DIALOG({ id, data }));
+ expect(state[id]).toEqual({ open: true, data });
+ });
+
+ it('CLOSE_DIALOG', () => {
+ const id = 'test id';
+ const state = dialogReducer({}, dialogActions.CLOSE_DIALOG({ id }));
+ expect(state[id]).toEqual({ open: false });
+ });
+
+ it('CLOSE_DIALOG persist data', () => {
+ const id = 'test id';
+ const [newState] = [{}]
+ .map(state => dialogReducer(state, dialogActions.OPEN_DIALOG({ id, data: 'test data' })))
+ .map(state => dialogReducer(state, dialogActions.CLOSE_DIALOG({ id })));
+
+ expect(newState[id]).toEqual({ open: false, data: 'test data' });
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DialogAction, dialogActions } from "./dialog-actions";
+
+export type DialogState = Record<string, Dialog>;
+
+export interface Dialog {
+ open: boolean;
+ data?: any;
+}
+
+export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
+ dialogActions.match(action, {
+ OPEN_DIALOG: ({ id, data }) => ({ ...state, [id]: { open: true, data } }),
+ CLOSE_DIALOG: ({ id }) => ({
+ ...state,
+ [id]: state[id] ? { ...state[id], open: false } : { open: false } }),
+ default: () => state,
+ });
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { DialogState } from './dialog-reducer';
+import { Dispatch } from 'redux';
+import { dialogActions } from './dialog-actions';
+
+export type WithDialog<T> = {
+ open: boolean;
+ data?: T;
+};
+
+export type WithDialogActions = {
+ closeDialog: () => void;
+};
+
+export const withDialog = (id: string) =>
+ <T>(component: React.ComponentType<WithDialog<T> & WithDialogActions>) =>
+ connect(mapStateToProps(id), mapDispatchToProps(id))(component);
+
+export const mapStateToProps = (id: string) => <T>(state: { dialog: DialogState }): WithDialog<T> => {
+ const dialog = state.dialog[id];
+ return dialog ? dialog : { open: false };
+};
+
+export const mapDispatchToProps = (id: string) => (dispatch: Dispatch): WithDialogActions => ({
+ closeDialog: () => {
+ dispatch(dialogActions.CLOSE_DIALOG({ id }));
+ }
+});
\ No newline at end of file
import { reducer as formReducer } from 'redux-form';
import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
import { snackbarReducer, SnackbarState } from './snackbar/snackbar-reducer';
+import { CollectionPanelFilesState } from './collection-panel/collection-panel-files/collection-panel-files-state';
+import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer';
import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
import { CollectionPanelState, collectionPanelReducer } from './collection-panel/collection-panel-reducer';
+import { DialogState, dialogReducer } from './dialog/dialog-reducer';
import { CollectionsState, collectionsReducer } from './collections/collections-reducer';
import { ServiceRepository } from "../services/services";
contextMenu: ContextMenuState;
favorites: FavoritesState;
snackbar: SnackbarState;
+ collectionPanelFiles: CollectionPanelFilesState;
+ dialog: DialogState;
}
export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
export function configureStore(history: History, services: ServiceRepository): RootStore {
- const rootReducer = combineReducers({
- auth: authReducer(services),
- projects: projectsReducer,
+ const rootReducer = combineReducers({
+ auth: authReducer(services),
+ projects: projectsReducer,
collections: collectionsReducer,
- router: routerReducer,
- dataExplorer: dataExplorerReducer,
- sidePanel: sidePanelReducer,
- collectionPanel: collectionPanelReducer,
- detailsPanel: detailsPanelReducer,
- contextMenu: contextMenuReducer,
- form: formReducer,
- favorites: favoritesReducer,
- snackbar: snackbarReducer,
- });
+ router: routerReducer,
+ dataExplorer: dataExplorerReducer,
+ sidePanel: sidePanelReducer,
+ collectionPanel: collectionPanelReducer,
+ detailsPanel: detailsPanelReducer,
+ contextMenu: contextMenuReducer,
+ form: formReducer,
+ favorites: favoritesReducer,
+ snackbar: snackbarReducer,
+ collectionPanelFiles: collectionPanelFilesReducer,
+ dialog: dialogReducer
+ });
const projectPanelMiddleware = dataExplorerMiddleware(
new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { CollectionPanelFiles as Component, CollectionPanelFilesProps } from "../../components/collection-panel-files/collection-panel-files";
+import { RootState } from "../../store/store";
+import { TreeItemStatus, TreeItem } from "../../components/tree/tree";
+import { CollectionPanelFilesState, CollectionPanelDirectory, CollectionPanelFile } from "../../store/collection-panel/collection-panel-files/collection-panel-files-state";
+import { FileTreeData } from "../../components/file-tree/file-tree-data";
+import { Dispatch } from "redux";
+import { collectionPanelFilesAction } from "../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { contextMenuActions } from "../../store/context-menu/context-menu-actions";
+import { ContextMenuKind } from "../context-menu/context-menu";
+import { Tree, getNodeChildren, getNode } from "../../models/tree";
+import { CollectionFileType } from "../../models/collection-file";
+
+const memoizedMapStateToProps = () => {
+ let prevState: CollectionPanelFilesState;
+ let prevTree: Array<TreeItem<FileTreeData>>;
+
+ return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
+ if (prevState !== state.collectionPanelFiles) {
+ prevState = state.collectionPanelFiles;
+ prevTree = getNodeChildren('')(state.collectionPanelFiles)
+ .map(collectionItemToTreeItem(state.collectionPanelFiles));
+ }
+ return {
+ items: prevTree
+ };
+ };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+ onUploadDataClick: () => { return; },
+ onCollapseToggle: (id) => {
+ dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+ },
+ onSelectionToggle: (event, item) => {
+ dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+ },
+ onItemMenuOpen: (event, item) => {
+ event.preventDefault();
+ dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
+ position: { x: event.clientX, y: event.clientY },
+ resource: { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id }
+ }));
+ },
+ onOptionsMenuOpen: (event) =>
+ dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
+ position: { x: event.clientX, y: event.clientY },
+ resource: { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' }
+ }))
+});
+
+
+export const CollectionPanelFiles = connect(memoizedMapStateToProps(), mapDispatchToProps)(Component);
+
+const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | CollectionPanelFile>) =>
+ (id: string): TreeItem<FileTreeData> => {
+ const node = getNode(id)(tree) || {
+ id: '',
+ children: [],
+ parent: '',
+ value: {
+ name: 'Invalid node',
+ type: CollectionFileType.DIRECTORY,
+ selected: false,
+ collapsed: true
+ }
+ };
+ return {
+ active: false,
+ data: {
+ name: node.value.name,
+ size: node.value.type === CollectionFileType.FILE ? node.value.size : undefined,
+ type: node.value.type
+ },
+ id: node.id,
+ items: getNodeChildren(node.id)(tree)
+ .map(collectionItemToTreeItem(tree)),
+ open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
+ selected: node.value.selected,
+ status: TreeItemStatus.LOADED
+ };
+ };
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { collectionPanelFilesAction } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { openRemoveDialog } from "../../remove-dialog/remove-dialog";
+
+
+export const collectionFilesActionSet: ContextMenuActionSet = [[{
+ name: "Select all",
+ execute: (dispatch) => {
+ dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+ }
+},{
+ name: "Unselect all",
+ execute: (dispatch) => {
+ dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
+ }
+},{
+ name: "Remove selected",
+ execute: (dispatch, resource) => {
+ dispatch(openRemoveDialog('selected files'));
+ }
+},{
+ name: "Download selected",
+ execute: (dispatch, resource) => {
+ return;
+ }
+},{
+ name: "Create a new collection with selected",
+ execute: (dispatch, resource) => {
+ return;
+ }
+}]];
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { RenameIcon, DownloadIcon, RemoveIcon } from "../../../components/icon/icon";
+import { openRemoveDialog } from "../../remove-dialog/remove-dialog";
+import { openRenameDialog } from "../../rename-dialog/rename-dialog";
+
+
+export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
+ name: "Rename",
+ icon: RenameIcon,
+ execute: (dispatch, resource) => {
+ dispatch(openRenameDialog('the item'));
+ }
+},{
+ name: "Download",
+ icon: DownloadIcon,
+ execute: (dispatch, resource) => {
+ return;
+ }
+},{
+ name: "Remove",
+ icon: RemoveIcon,
+ execute: (dispatch, resource) => {
+ dispatch(openRemoveDialog('selected file'));
+ }
+}]];
PROJECT = "Project",
RESOURCE = "Resource",
FAVORITE = "Favorite",
+ COLLECTION_FILES = "CollectionFiles",
+ COLLECTION_FILES_ITEM = "CollectionFilesItem",
COLLECTION = 'Collection'
}
const mapStateToProps = (state: RootState, { id }: Props) =>
getDataExplorer(state.dataExplorer, id);
-const mapDispatchToProps = (dispatch: Dispatch, { id, columns, onRowClick, onRowDoubleClick, onContextMenu }: Props) => {
- dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
- return {
- onSearch: (searchValue: string) => {
- dispatch(dataExplorerActions.SET_SEARCH_VALUE({ id, searchValue }));
- },
+const mapDispatchToProps = () => {
+ let prevColumns: DataColumns<any>;
+ return (dispatch: Dispatch, { id, columns, onRowClick, onRowDoubleClick, onContextMenu }: Props) => {
+ if (columns !== prevColumns) {
+ prevColumns = columns;
+ dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
+ }
+ return {
+ onSearch: (searchValue: string) => {
+ dispatch(dataExplorerActions.SET_SEARCH_VALUE({ id, searchValue }));
+ },
- onColumnToggle: (column: DataColumn<any>) => {
- dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
- },
+ onColumnToggle: (column: DataColumn<any>) => {
+ dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
+ },
- onSortToggle: (column: DataColumn<any>) => {
- dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
- },
+ onSortToggle: (column: DataColumn<any>) => {
+ dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
+ },
- onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<any>) => {
- dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
- },
+ onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<any>) => {
+ dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
+ },
- onChangePage: (page: number) => {
- dispatch(dataExplorerActions.SET_PAGE({ id, page }));
- },
+ onChangePage: (page: number) => {
+ dispatch(dataExplorerActions.SET_PAGE({ id, page }));
+ },
- onChangeRowsPerPage: (rowsPerPage: number) => {
- dispatch(dataExplorerActions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
- },
+ onChangeRowsPerPage: (rowsPerPage: number) => {
+ dispatch(dataExplorerActions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
+ },
- onRowClick,
+ onRowClick,
- onRowDoubleClick,
+ onRowDoubleClick,
- onContextMenu,
+ onContextMenu,
+ };
};
};
-export const DataExplorer = connect(mapStateToProps, mapDispatchToProps)(DataExplorerComponent);
+export const DataExplorer = connect(mapStateToProps, mapDispatchToProps())(DataExplorerComponent);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+
+export const REMOVE_DIALOG = 'removeCollectionFilesDialog';
+
+export const RemoveDialog = withDialog(REMOVE_DIALOG)(
+ (props) =>
+ <Dialog open={props.open}>
+ <DialogTitle>{`Removing ${props.data}`}</DialogTitle>
+ <DialogContent>
+ {`Are you sure you want to remove ${props.data}?`}
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ onClick={props.closeDialog}>
+ Cancel
+ </Button>
+ <Button variant='raised' color='primary'>
+ Remove
+ </Button>
+ </DialogActions>
+ </Dialog>
+);
+
+export const openRemoveDialog = (removedDataName: string) =>
+ dialogActions.OPEN_DIALOG({ id: REMOVE_DIALOG, data: removedDataName });
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography } from "@material-ui/core";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+
+export const RENAME_DIALOG = 'nameDialog';
+
+export const RenameDialog = withDialog(RENAME_DIALOG)(
+ (props) =>
+ <Dialog open={props.open}>
+ <DialogTitle>{`Rename`}</DialogTitle>
+ <DialogContent>
+ <Typography variant='body1' gutterBottom>
+ {`Please, enter a new name for ${props.data}`}
+ </Typography>
+ <TextField fullWidth={true} placeholder='New name' />
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ onClick={props.closeDialog}>
+ Cancel
+ </Button>
+ <Button variant='raised' color='primary'>
+ Ok
+ </Button>
+ </DialogActions>
+ </Dialog>
+);
+
+export const openRenameDialog = (originalName: string, ) =>
+ dialogActions.OPEN_DIALOG({ id: RENAME_DIALOG, data: originalName });
import { MoreOptionsIcon, CollectionIcon, CopyIcon } 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 * as CopyToClipboard from 'react-copy-to-clipboard';
type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon';
</Grid>
</CardContent>
</Card>
-
- <Card className={classes.card}>
- <CardHeader title="Files" />
- <CardContent>
- <Grid container direction="column">
- <Grid item xs={4}>
- Tags
- </Grid>
- </Grid>
- </CardContent>
- </Card>
+ <div className={classes.card}>
+ <CollectionPanelFiles/>
+ </div>
</div>;
}
import { CollectionPanel } from '../collection-panel/collection-panel';
import { loadCollection } from '../../store/collection-panel/collection-panel-action';
import { getCollectionUrl } from '../../models/collection';
+import { RemoveDialog } from '../../views-components/remove-dialog/remove-dialog';
+import { RenameDialog } from '../../views-components/rename-dialog/rename-dialog';
import { UpdateCollectionDialog } from '../../views-components/update-collection-dialog/update-collection-dialog.';
import { AuthService } from "../../services/auth-service/auth-service";
<Snackbar />
<CreateProjectDialog />
<CreateCollectionDialog />
+ <RemoveDialog />
+ <RenameDialog />
<UpdateCollectionDialog />
<CurrentTokenDialog
currentToken={this.props.currentToken}
);
}
- renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
+ renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
onItemRouteChange={(collectionId) => this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION))}
onContextMenu={(event, item) => {
this.openContextMenu(event, {
version "10.5.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707"
+"@types/react-copy-to-clipboard@4.2.5":
+ version "4.2.5"
+ resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.2.5.tgz#bda288b4256288676019b75ca86f1714bbd206d4"
+ dependencies:
+ "@types/react" "*"
+
"@types/react-dom@16.0.6":
version "16.0.6"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.6.tgz#f1a65a4e7be8ed5d123f8b3b9eacc913e35a1a3c"
version "0.1.1"
resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+copy-to-clipboard@^3:
+ version "3.0.8"
+ resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9"
+ dependencies:
+ toggle-selection "^1.0.3"
+
core-js@^1.0.0:
version "1.2.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
minimist "^1.2.0"
strip-json-comments "~2.0.1"
+react-copy-to-clipboard@5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e"
+ dependencies:
+ copy-to-clipboard "^3"
+ prop-types "^15.5.8"
+
react-dev-utils@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-5.0.1.tgz#1f396e161fe44b595db1b186a40067289bf06613"
regex-not "^1.0.2"
safe-regex "^1.1.0"
+toggle-selection@^1.0.3:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
+
toposort@^1.0.0:
version "1.0.7"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"