merge-conflicts
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 25 Jun 2018 10:05:09 +0000 (12:05 +0200)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 25 Jun 2018 10:05:09 +0000 (12:05 +0200)
Feature ##13598

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

59 files changed:
Makefile
__mocks__/popper.js.js [new file with mode: 0644]
package.json
src/common/formatters.ts [new file with mode: 0644]
src/components/breadcrumbs/breadcrumbs.test.tsx
src/components/breadcrumbs/breadcrumbs.tsx
src/components/column-selector/column-selector.test.tsx [new file with mode: 0644]
src/components/column-selector/column-selector.tsx [new file with mode: 0644]
src/components/context-menu/context-menu.test.tsx [new file with mode: 0644]
src/components/context-menu/context-menu.tsx [new file with mode: 0644]
src/components/data-explorer/data-explorer.test.tsx [new file with mode: 0644]
src/components/data-explorer/data-explorer.tsx [new file with mode: 0644]
src/components/data-table-filters/data-table-filters.test.tsx [new file with mode: 0644]
src/components/data-table-filters/data-table-filters.tsx [new file with mode: 0644]
src/components/data-table/data-column.ts [new file with mode: 0644]
src/components/data-table/data-table.test.tsx [new file with mode: 0644]
src/components/data-table/data-table.tsx [new file with mode: 0644]
src/components/dropdown-menu/dropdown-menu.test.tsx [moved from src/components/main-app-bar/dropdown-menu/dropdown-menu.test.tsx with 100% similarity]
src/components/dropdown-menu/dropdown-menu.tsx [moved from src/components/main-app-bar/dropdown-menu/dropdown-menu.tsx with 100% similarity]
src/components/popover/helpers.ts [new file with mode: 0644]
src/components/popover/popover.test.tsx [new file with mode: 0644]
src/components/popover/popover.tsx [new file with mode: 0644]
src/components/search-bar/search-bar.test.tsx [moved from src/components/main-app-bar/search-bar/search-bar.test.tsx with 100% similarity]
src/components/search-bar/search-bar.tsx [moved from src/components/main-app-bar/search-bar/search-bar.tsx with 100% similarity]
src/components/search-input/search-input.test.tsx [new file with mode: 0644]
src/components/search-input/search-input.tsx [new file with mode: 0644]
src/components/tree/tree.tsx
src/index.tsx
src/models/collection.ts [new file with mode: 0644]
src/models/project.ts
src/models/resource.ts [new file with mode: 0644]
src/services/auth-service/auth-service.ts
src/services/collection-service/collection-service.ts [new file with mode: 0644]
src/services/project-service/project-service.ts
src/services/response.ts [new file with mode: 0644]
src/services/services.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/collection/collection-action.ts [new file with mode: 0644]
src/store/collection/collection-reducer.test.ts [new file with mode: 0644]
src/store/collection/collection-reducer.ts [new file with mode: 0644]
src/store/project/project-action.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/store/store.ts
src/views-components/api-token/api-token.tsx [moved from src/components/api-token/api-token.tsx with 77% similarity]
src/views-components/main-app-bar/main-app-bar.test.tsx [moved from src/components/main-app-bar/main-app-bar.test.tsx with 95% similarity]
src/views-components/main-app-bar/main-app-bar.tsx [moved from src/components/main-app-bar/main-app-bar.tsx with 94% similarity]
src/views-components/project-explorer/project-explorer-item.ts [new file with mode: 0644]
src/views-components/project-explorer/project-explorer.tsx [new file with mode: 0644]
src/views-components/project-list/project-list.tsx [moved from src/components/project-list/project-list.tsx with 100% similarity]
src/views-components/project-tree/project-tree.test.tsx [moved from src/components/project-tree/project-tree.test.tsx with 89% similarity]
src/views-components/project-tree/project-tree.tsx [moved from src/components/project-tree/project-tree.tsx with 94% similarity]
src/views/project-panel/project-panel-selectors.ts [new file with mode: 0644]
src/views/project-panel/project-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.test.tsx
src/views/workbench/workbench.tsx
yarn.lock

index a543d4649364f0b1a5e87abf02ad39b8dce45e29..30ab7bc4aca8e48159a96f214b5c756a16ae7f65 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,10 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+# Use bash, and run all lines in each recipe as one shell command
+SHELL := /bin/bash
+.ONESHELL:
+
 APP_NAME?=arvados-workbench2
 
 # GIT_TAG is the last tagged stable release (i.e. 1.2.0)
@@ -34,7 +38,7 @@ DEST_DIR=/var/www/arvados-workbench2/workbench2/
 DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
 
 # redHat package file
-RPM_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION).x86_64.rpm
+RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm
 
 export WORKSPACE?=$(shell pwd)
 
@@ -94,5 +98,16 @@ $(RPM_FILE): build
         --description="$(DESCRIPTION)" \
         $(WORKSPACE)/build/=$(DEST_DIR)
 
+copy: $(DEB_FILE) $(RPM_FILE)
+       for target in $(TARGETS); do \
+               if [[ $$target =~ ^centos ]]; then
+                       cp -p $(RPM_FILE) packages/$$target ; \
+               else
+                       cp -p $(DEB_FILE) packages/$$target ; \
+               fi
+       done
+       rm -f $(RPM_FILE)
+       rm -f $(DEB_FILE)
+
 # use FPM to create DEB and RPM
-packages: $(DEB_FILE) $(RPM_FILE)
+packages: copy
diff --git a/__mocks__/popper.js.js b/__mocks__/popper.js.js
new file mode 100644 (file)
index 0000000..07c7856
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export default class Popper {
+    static placements = [
+        'auto',
+        'auto-end',
+        'auto-start',
+        'bottom',
+        'bottom-end',
+        'bottom-start',
+        'left',
+        'left-end',
+        'left-start',
+        'right',
+        'right-end',
+        'right-start',
+        'top',
+        'top-end',
+        'top-start'
+    ];
+
+    constructor() {
+        return {
+            destroy: jest.fn(),
+            scheduleUpdate: jest.fn()
+        };
+    }
+}
\ No newline at end of file
index fda2ead62ec4895668d94e3aa0e7410d4a570904..3db240455871d95eecbcb89bb0014d0488a9ebea 100644 (file)
@@ -7,6 +7,7 @@
     "@material-ui/icons": "1.1.0",
     "@types/lodash": "4.14.109",
     "axios": "0.18.0",
+    "classnames": "^2.2.6",
     "lodash": "4.17.10",
     "react": "16.4.1",
     "react-dom": "16.4.1",
     "lint": "tslint src/** -t verbose"
   },
   "devDependencies": {
+    "@types/classnames": "^2.2.4",
     "@types/enzyme": "3.1.10",
     "@types/enzyme-adapter-react-16": "1.0.2",
     "@types/jest": "23.1.0",
     "@types/node": "10.3.3",
-    "@types/react": "16.3.18",
+    "@types/react": "16.3",
     "@types/react-dom": "16.0.6",
     "@types/react-redux": "6.0.2",
     "@types/react-router": "4.0.26",
diff --git a/src/common/formatters.ts b/src/common/formatters.ts
new file mode 100644 (file)
index 0000000..1d9a520
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const formatDate = (isoDate: string) => {
+    const date = new Date(isoDate);
+    return date.toLocaleString();
+};
+
+export const formatFileSize = (size?: number) => {
+    if (typeof size === "number") {
+        for (const { base, unit } of fileSizes) {
+            if (size >= base) {
+                return `${(size / base).toFixed()} ${unit}`;
+            }
+        }
+    }
+    return "";
+};
+
+const fileSizes = [
+    {
+        base: 1000000000000,
+        unit: "TB"
+    },
+    {
+        base: 1000000000,
+        unit: "GB"
+    },
+    {
+        base: 1000000,
+        unit: "MB"
+    },
+    {
+        base: 1000,
+        unit: "KB"
+    },
+    {
+        base: 1,
+        unit: "B"
+    }
+];
\ No newline at end of file
index 77beb49478783c6ea308d27072ad7b20da342684..b525554a2c8031d80464c5d2f070b55cfbf9b57c 100644 (file)
@@ -22,29 +22,29 @@ describe("<Breadcrumbs />", () => {
 
     it("renders one item", () => {
         const items = [
-            {label: 'breadcrumb 1'}
+            { label: 'breadcrumb 1' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick}  />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
         expect(breadcrumbs.find(Button)).toHaveLength(1);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
     });
-    
+
     it("renders multiple items", () => {
         const items = [
-            {label: 'breadcrumb 1'},
-            {label: 'breadcrumb 2'}
+            { label: 'breadcrumb 1' },
+            { label: 'breadcrumb 2' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick}  />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
         expect(breadcrumbs.find(Button)).toHaveLength(2);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
     });
-    
+
     it("calls onClick with clicked item", () => {
         const items = [
-            {label: 'breadcrumb 1'},
-            {label: 'breadcrumb 2'}
+            { label: 'breadcrumb 1' },
+            { label: 'breadcrumb 2' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick}  />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
         breadcrumbs.find(Button).at(1).simulate('click');
         expect(onClick).toBeCalledWith(items[1]);
     });
index 25f30a1bdae5da9abc02fb6a9f7bcb511ce7a942..41f71981e57eea29d54064f5888c78961f283f89 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Button, Grid, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } from '@material-ui/core';
 import ChevronRightIcon from '@material-ui/icons/ChevronRight';
 import { withStyles } from '@material-ui/core';
 
@@ -17,19 +17,27 @@ interface BreadcrumbsProps {
 }
 
 const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, items }) => {
-    return <Grid container alignItems="center">
+    return <Grid container alignItems="center" wrap="nowrap">
         {
             items.map((item, index) => {
                 const isLastItem = index === items.length - 1;
                 return (
                     <React.Fragment key={index}>
-                        <Button
-                            color="inherit"
-                            className={isLastItem ? classes.currentItem : classes.item}
-                            onClick={() => onClick(item)}
-                        >
-                            {item.label}
-                        </Button>
+                        <Tooltip title={item.label}>
+                            <Button
+                                color="inherit"
+                                className={isLastItem ? classes.currentItem : classes.item}
+                                onClick={() => onClick(item)}
+                            >
+                                <Typography
+                                    noWrap
+                                    color="inherit"
+                                    className={classes.label}
+                                >
+                                    {item.label}
+                                </Typography>
+                            </Button>
+                        </Tooltip>
                         {
                             !isLastItem && <ChevronRightIcon color="inherit" className={classes.item} />
                         }
@@ -40,7 +48,7 @@ const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ class
     </Grid>;
 };
 
-type CssRules = "item" | "currentItem";
+type CssRules = "item" | "currentItem" | "label";
 
 const styles: StyleRulesCallback<CssRules> = theme => {
     const { unit } = theme.spacing;
@@ -50,6 +58,9 @@ const styles: StyleRulesCallback<CssRules> = theme => {
         },
         currentItem: {
             opacity: 1
+        },
+        label: {
+            textTransform: "none"
         }
     };
 };
diff --git a/src/components/column-selector/column-selector.test.tsx b/src/components/column-selector/column-selector.test.tsx
new file mode 100644 (file)
index 0000000..b6c544f
--- /dev/null
@@ -0,0 +1,79 @@
+// 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 ColumnSelector, { ColumnSelectorProps, ColumnSelectorTrigger } from "./column-selector";
+import { DataColumn } from "../data-table/data-column";
+import { ListItem, Checkbox } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<ColumnSelector />", () => {
+    it("shows only configurable columns", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true,
+                configurable: false
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(ListItem)).toHaveLength(2);
+    });
+
+    it("renders checked checkboxes next to selected columns", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: false
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(Checkbox).at(0).prop("checked")).toBe(true);
+        expect(columnsConfigurator.find(Checkbox).at(1).prop("checked")).toBe(false);
+        expect(columnsConfigurator.find(Checkbox).at(2).prop("checked")).toBe(true);
+    });
+
+    it("calls onColumnToggle with clicked column", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const onColumnToggle = jest.fn();
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={onColumnToggle} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        columnsConfigurator.find(ListItem).simulate("click");
+        expect(onColumnToggle).toHaveBeenCalledWith(columns[0]);
+    });
+});
diff --git a/src/components/column-selector/column-selector.tsx b/src/components/column-selector/column-selector.tsx
new file mode 100644 (file)
index 0000000..e2286b0
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithStyles, StyleRulesCallback, Theme, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem } from '@material-ui/core';
+import MenuIcon from "@material-ui/icons/Menu";
+import { DataColumn, isColumnConfigurable } from '../data-table/data-column';
+import Popover from "../popover/popover";
+import { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface ColumnSelectorProps {
+    columns: Array<DataColumn<any>>;
+    onColumnToggle: (column: DataColumn<any>) => void;
+}
+
+const ColumnSelector: React.SFC<ColumnSelectorProps & WithStyles<CssRules>> = ({ columns, onColumnToggle, classes }) =>
+    <Popover triggerComponent={ColumnSelectorTrigger}>
+        <Paper>
+            <List dense>
+                {columns
+                    .filter(isColumnConfigurable)
+                    .map((column, index) => (
+                        <ListItem
+                            button
+                            key={index}
+                            onClick={() => onColumnToggle(column)}>
+                            <Checkbox
+                                disableRipple
+                                color="primary"
+                                checked={column.selected}
+                                className={classes.checkbox} />
+                            <ListItemText>
+                                {column.name}
+                            </ListItemText>
+                        </ListItem>
+                    ))}
+            </List>
+        </Paper>
+    </Popover>;
+
+export const ColumnSelectorTrigger: React.SFC<IconButtonProps> = (props) =>
+    <IconButton {...props}>
+        <MenuIcon />
+    </IconButton>;
+
+type CssRules = "checkbox";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    checkbox: {
+        width: 24,
+        height: 24
+    }
+});
+
+export default withStyles(styles)(ColumnSelector);
diff --git a/src/components/context-menu/context-menu.test.tsx b/src/components/context-menu/context-menu.test.tsx
new file mode 100644 (file)
index 0000000..e4e2397
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure, shallow } from "enzyme";
+import * as Adapter from "enzyme-adapter-react-16";
+import ContextMenu from "./context-menu";
+import { ListItem } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<ContextMenu />", () => {
+    const actions = [[{
+        icon: "",
+        name: "Action 1.1"
+    }, {
+        icon: "",
+        name: "Action 1.2"
+    },], [{
+        icon: "",
+        name: "Action 2.1"
+    }]];
+
+    it("calls onActionClick with clicked action", () => {
+        const onActionClick = jest.fn();
+        const contextMenu = mount(<ContextMenu
+            anchorEl={document.createElement("div")}
+            onClose={jest.fn()}
+            onActionClick={onActionClick}
+            actions={actions} />);
+        contextMenu.find(ListItem).at(2).simulate("click");
+        expect(onActionClick).toHaveBeenCalledWith(actions[1][0]);
+    });
+});
\ No newline at end of file
diff --git a/src/components/context-menu/context-menu.tsx b/src/components/context-menu/context-menu.tsx
new file mode 100644 (file)
index 0000000..7751be4
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import * as React from "react";
+import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
+import { DefaultTransformOrigin } from "../popover/helpers";
+
+export interface ContextMenuAction {
+    name: string;
+    icon: string;
+}
+
+export type ContextMenuActionGroup = ContextMenuAction[];
+
+export interface ContextMenuProps<T> {
+    anchorEl?: HTMLElement;
+    actions: ContextMenuActionGroup[];
+    onActionClick: (action: ContextMenuAction) => void;
+    onClose: () => void;
+}
+
+export default class ContextMenu<T> extends React.PureComponent<ContextMenuProps<T>> {
+    render() {
+        const { anchorEl, actions, onClose, onActionClick } = this.props;
+        return <Popover
+            anchorEl={anchorEl}
+            open={!!anchorEl}
+            onClose={onClose}
+            transformOrigin={DefaultTransformOrigin}
+            anchorOrigin={DefaultTransformOrigin}>
+            <List dense>
+                {actions.map((group, groupIndex) =>
+                    <React.Fragment key={groupIndex}>
+                        {group.map((action, actionIndex) =>
+                            <ListItem
+                                button
+                                key={actionIndex}
+                                onClick={() => onActionClick(action)}>
+                                <ListItemIcon>
+                                    <i className={action.icon} />
+                                </ListItemIcon>
+                                <ListItemText>
+                                    {action.name}
+                                </ListItemText>
+                            </ListItem>)}
+                        {groupIndex < actions.length - 1 && <Divider />}
+                    </React.Fragment>)}
+            </List>
+        </Popover>;
+    }
+}
diff --git a/src/components/data-explorer/data-explorer.test.tsx b/src/components/data-explorer/data-explorer.test.tsx
new file mode 100644 (file)
index 0000000..eff4992
--- /dev/null
@@ -0,0 +1,130 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { configure, mount } from "enzyme";
+import * as Adapter from 'enzyme-adapter-react-16';
+
+import DataExplorer from "./data-explorer";
+import ContextMenu from "../context-menu/context-menu";
+import ColumnSelector from "../column-selector/column-selector";
+import DataTable from "../data-table/data-table";
+import SearchInput from "../search-input/search-input";
+import { TablePagination } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataExplorer />", () => {
+
+    it("communicates with <ContextMenu/>", () => {
+        const onContextAction = jest.fn();
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            contextActions={[]}
+            onContextAction={onContextAction}
+            items={["Item 1"]}
+            columns={[{ name: "Column 1", render: jest.fn(), selected: true }]} />);
+        expect(dataExplorer.find(ContextMenu).prop("actions")).toEqual([]);
+        dataExplorer.find(DataTable).prop("onRowContextMenu")({
+            preventDefault: jest.fn()
+        }, "Item 1");
+        dataExplorer.find(ContextMenu).prop("onActionClick")({ name: "Action 1", icon: "" });
+        expect(onContextAction).toHaveBeenCalledWith({ name: "Action 1", icon: "" }, "Item 1");
+    });
+
+    it("communicates with <SearchInput/>", () => {
+        const onSearch = jest.fn();
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            items={["item 1"]}
+            searchValue="search value"
+            onSearch={onSearch} />);
+        expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value");
+        dataExplorer.find(SearchInput).prop("onSearch")("new value");
+        expect(onSearch).toHaveBeenCalledWith("new value");
+    });
+
+    it("communicates with <ColumnSelector/>", () => {
+        const onColumnToggle = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true }];
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            columns={columns}
+            onColumnToggle={onColumnToggle}
+            contextActions={[]}
+            items={["Item 1"]} />);
+        expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
+        dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
+        expect(onColumnToggle).toHaveBeenCalledWith("columns");
+    });
+
+    it("communicates with <DataTable/>", () => {
+        const onFiltersChange = jest.fn();
+        const onSortToggle = jest.fn();
+        const onRowClick = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true }];
+        const items = ["Item 1"];
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            columns={columns}
+            items={items}
+            onFiltersChange={onFiltersChange}
+            onSortToggle={onSortToggle}
+            onRowClick={onRowClick} />);
+        expect(dataExplorer.find(DataTable).prop("columns")).toBe(columns);
+        expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
+        dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
+        dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange");
+        dataExplorer.find(DataTable).prop("onSortToggle")("sortToggle");
+        expect(onFiltersChange).toHaveBeenCalledWith("filtersChange");
+        expect(onSortToggle).toHaveBeenCalledWith("sortToggle");
+        expect(onRowClick).toHaveBeenCalledWith("rowClick");
+    });
+
+    it("does not render <SearchInput/>, <ColumnSelector/> and <TablePagination/> if there is no items", () => {
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            items={[]}
+        />);
+        expect(dataExplorer.find(SearchInput)).toHaveLength(0);
+        expect(dataExplorer.find(ColumnSelector)).toHaveLength(0);
+        expect(dataExplorer.find(TablePagination)).toHaveLength(0);
+    });
+
+    it("communicates with <TablePagination/>", () => {
+        const onChangePage = jest.fn();
+        const onChangeRowsPerPage = jest.fn();
+        const dataExplorer = mount(<DataExplorer
+            {...mockDataExplorerProps()}
+            items={["Item 1"]}
+            page={10}
+            rowsPerPage={50}
+            onChangePage={onChangePage}
+            onChangeRowsPerPage={onChangeRowsPerPage}
+        />);
+        expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
+        expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
+        dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
+        dataExplorer.find(TablePagination).prop("onChangeRowsPerPage")({ target: { value: 10 } });
+        expect(onChangePage).toHaveBeenCalledWith(6);
+        expect(onChangeRowsPerPage).toHaveBeenCalledWith(10);
+    });
+});
+
+const mockDataExplorerProps = () => ({
+    columns: [],
+    items: [],
+    contextActions: [],
+    searchValue: "",
+    page: 0,
+    rowsPerPage: 0,
+    onSearch: jest.fn(),
+    onFiltersChange: jest.fn(),
+    onSortToggle: jest.fn(),
+    onRowClick: jest.fn(),
+    onColumnToggle: jest.fn(),
+    onContextAction: jest.fn(),
+    onChangePage: jest.fn(),
+    onChangeRowsPerPage: jest.fn()
+});
\ No newline at end of file
diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx
new file mode 100644 (file)
index 0000000..6a3103b
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, Theme, WithStyles, TablePagination, Table, IconButton } from '@material-ui/core';
+import MoreVertIcon from "@material-ui/icons/MoreVert";
+import ContextMenu, { ContextMenuActionGroup, ContextMenuAction } from "../../components/context-menu/context-menu";
+import ColumnSelector from "../../components/column-selector/column-selector";
+import DataTable from "../../components/data-table/data-table";
+import { mockAnchorFromMouseEvent } from "../../components/popover/helpers";
+import { DataColumn, toggleSortDirection } from "../../components/data-table/data-column";
+import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
+import SearchInput from '../search-input/search-input';
+
+interface DataExplorerProps<T> {
+    items: T[];
+    columns: Array<DataColumn<T>>;
+    contextActions: ContextMenuActionGroup[];
+    searchValue: string;
+    rowsPerPage: number;
+    page: number;
+    onSearch: (value: string) => void;
+    onRowClick: (item: T) => void;
+    onColumnToggle: (column: DataColumn<T>) => void;
+    onContextAction: (action: ContextMenuAction, item: T) => void;
+    onSortToggle: (column: DataColumn<T>) => void;
+    onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
+    onChangePage: (page: number) => void;
+    onChangeRowsPerPage: (rowsPerPage: number) => void;
+}
+
+interface DataExplorerState<T> {
+    contextMenu: {
+        anchorEl?: HTMLElement;
+        item?: T;
+    };
+}
+
+class DataExplorer<T> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>, DataExplorerState<T>> {
+    state: DataExplorerState<T> = {
+        contextMenu: {}
+    };
+
+    render() {
+        return <Paper>
+            <ContextMenu
+                anchorEl={this.state.contextMenu.anchorEl}
+                actions={this.props.contextActions}
+                onActionClick={this.callAction}
+                onClose={this.closeContextMenu} />
+            <Toolbar className={this.props.classes.toolbar}>
+                {this.props.items.length > 0 &&
+                    <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+                        <div className={this.props.classes.searchBox}>
+                            <SearchInput
+                                value={this.props.searchValue}
+                                onSearch={this.props.onSearch} />
+                        </div>
+                        <ColumnSelector
+                            columns={this.props.columns}
+                            onColumnToggle={this.props.onColumnToggle} />
+                    </Grid>}
+
+            </Toolbar>
+            <DataTable
+                columns={[
+                    ...this.props.columns,
+                    this.contextMenuColumn]}
+                items={this.props.items}
+                onRowClick={(_, item: T) => this.props.onRowClick(item)}
+                onRowContextMenu={this.openContextMenu}
+                onFiltersChange={this.props.onFiltersChange}
+                onSortToggle={this.props.onSortToggle} />
+            <Toolbar>
+                {this.props.items.length > 0 &&
+                    <Grid container justify="flex-end">
+                        <TablePagination
+                            count={this.props.items.length}
+                            rowsPerPage={this.props.rowsPerPage}
+                            page={this.props.page}
+                            onChangePage={this.changePage}
+                            onChangeRowsPerPage={this.changeRowsPerPage}
+                            component="div"
+                        />
+                    </Grid>}
+            </Toolbar>
+        </Paper>;
+    }
+
+    openContextMenu = (event: React.MouseEvent<HTMLElement>, item: T) => {
+        event.preventDefault();
+        this.setState({
+            contextMenu: {
+                anchorEl: mockAnchorFromMouseEvent(event),
+                item
+            }
+        });
+    }
+
+    closeContextMenu = () => {
+        this.setState({ contextMenu: {} });
+    }
+
+    callAction = (action: ContextMenuAction) => {
+        const { item } = this.state.contextMenu;
+        this.closeContextMenu();
+        if (item) {
+            this.props.onContextAction(action, item);
+        }
+    }
+
+    changePage = (event: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
+        this.props.onChangePage(page);
+    }
+
+    changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
+        this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
+    }
+
+    renderContextMenuTrigger = (item: T) =>
+        <Grid container justify="flex-end">
+            <IconButton onClick={event => this.openContextMenu(event, item)}>
+                <MoreVertIcon />
+            </IconButton>
+        </Grid>
+
+    contextMenuColumn = {
+        name: "Actions",
+        selected: true,
+        key: "context-actions",
+        renderHeader: () => null,
+        render: this.renderContextMenuTrigger
+    };
+
+}
+
+type CssRules = "searchBox" | "toolbar";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    searchBox: {
+        paddingBottom: theme.spacing.unit * 2
+    },
+    toolbar: {
+        paddingTop: theme.spacing.unit * 2
+    }
+});
+
+export default withStyles(styles)(DataExplorer);
diff --git a/src/components/data-table-filters/data-table-filters.test.tsx b/src/components/data-table-filters/data-table-filters.test.tsx
new file mode 100644 (file)
index 0000000..b2daebe
--- /dev/null
@@ -0,0 +1,68 @@
+// 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 DataTableFilter, { DataTableFilterItem } from "./data-table-filters";
+import * as Adapter from 'enzyme-adapter-react-16';
+import { Checkbox, ButtonBase, ListItem, Button, ListItemText } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTableFilter />", () => {
+    it("renders filters according to their state", () => {
+        const filters = [{
+            name: "Filter 1",
+            selected: true
+        }, {
+            name: "Filter 2",
+            selected: false
+        }];
+        const dataTableFilter = mount(<DataTableFilter name="" filters={filters} />);
+        dataTableFilter.find(ButtonBase).simulate("click");
+        expect(dataTableFilter.find(Checkbox).at(0).prop("checked")).toBeTruthy();
+        expect(dataTableFilter.find(Checkbox).at(1).prop("checked")).toBeFalsy();
+    });
+    
+    it("updates filters after filters prop change", () => {
+        const filters = [{
+            name: "Filter 1",
+            selected: true
+        }];
+        const updatedFilters = [, {
+            name: "Filter 2",
+            selected: true
+        }];
+        const dataTableFilter = mount(<DataTableFilter name="" filters={filters} />);
+        dataTableFilter.find(ButtonBase).simulate("click");
+        expect(dataTableFilter.find(Checkbox).prop("checked")).toBeTruthy();
+        dataTableFilter.find(ListItem).simulate("click");
+        expect(dataTableFilter.find(Checkbox).prop("checked")).toBeFalsy();
+        dataTableFilter.setProps({filters: updatedFilters});
+        expect(dataTableFilter.find(Checkbox).prop("checked")).toBeTruthy();
+        expect(dataTableFilter.find(ListItemText).text()).toBe("Filter 2");
+    });
+
+    it("calls onChange with modified list of filters", () => {
+        const filters = [{
+            name: "Filter 1",
+            selected: true
+        }, {
+            name: "Filter 2",
+            selected: false
+        }];
+        const onChange = jest.fn();
+        const dataTableFilter = mount(<DataTableFilter name="" filters={filters} onChange={onChange} />);
+        dataTableFilter.find(ButtonBase).simulate("click");
+        dataTableFilter.find(ListItem).at(1).simulate("click");
+        dataTableFilter.find(Button).at(0).simulate("click");
+        expect(onChange).toHaveBeenCalledWith([{
+            name: "Filter 1",
+            selected: true
+        }, {
+            name: "Filter 2",
+            selected: true
+        }]);
+    });
+});
\ No newline at end of file
diff --git a/src/components/data-table-filters/data-table-filters.tsx b/src/components/data-table-filters/data-table-filters.tsx
new file mode 100644 (file)
index 0000000..bede5ae
--- /dev/null
@@ -0,0 +1,188 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import {
+    WithStyles,
+    withStyles,
+    ButtonBase,
+    StyleRulesCallback,
+    Theme,
+    Popover,
+    List,
+    ListItem,
+    Checkbox,
+    ListItemText,
+    Button,
+    Card,
+    CardActions,
+    Typography,
+    CardContent
+} from "@material-ui/core";
+import * as classnames from "classnames";
+import { DefaultTransformOrigin } from "../popover/helpers";
+
+export interface DataTableFilterItem {
+    name: string;
+    selected: boolean;
+}
+
+export interface DataTableFilterProps {
+    name: string;
+    filters: DataTableFilterItem[];
+    onChange?: (filters: DataTableFilterItem[]) => void;
+}
+
+interface DataTableFilterState {
+    anchorEl?: HTMLElement;
+    filters: DataTableFilterItem[];
+    prevFilters: DataTableFilterItem[];
+}
+
+class DataTableFilter extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
+    state: DataTableFilterState = {
+        anchorEl: undefined,
+        filters: [],
+        prevFilters: []
+    };
+    icon = React.createRef<HTMLElement>();
+
+    render() {
+        const { name, classes, children } = this.props;
+        const isActive = this.state.filters.some(f => f.selected);
+        return <>
+            <ButtonBase
+                className={classnames([classes.root, { [classes.active]: isActive }])}
+                component="span"
+                onClick={this.open}
+                disableRipple>
+                {children}
+                <i className={classnames(["fas fa-filter", classes.icon])}
+                    data-fa-transform="shrink-3"
+                    ref={this.icon} />
+            </ButtonBase>
+            <Popover
+                anchorEl={this.state.anchorEl}
+                open={!!this.state.anchorEl}
+                anchorOrigin={DefaultTransformOrigin}
+                transformOrigin={DefaultTransformOrigin}
+                onClose={this.cancel}>
+                <Card>
+                    <CardContent>
+                        <Typography variant="caption">
+                            {name}
+                        </Typography>
+                    </CardContent>
+                    <List dense>
+                        {this.state.filters.map((filter, index) =>
+                            <ListItem
+                                button
+                                key={index}
+                                onClick={this.toggleFilter(filter)}>
+                                <Checkbox
+                                    disableRipple
+                                    color="primary"
+                                    checked={filter.selected}
+                                    className={classes.checkbox} />
+                                <ListItemText>
+                                    {filter.name}
+                                </ListItemText>
+                            </ListItem>
+                        )}
+                    </List>
+                    <CardActions>
+                        <Button
+                            color="primary"
+                            variant="raised"
+                            size="small"
+                            onClick={this.submit}>
+                            Ok
+                        </Button>
+                        <Button
+                            color="primary"
+                            variant="outlined"
+                            size="small"
+                            onClick={this.cancel}>
+                            Cancel
+                        </Button>
+                    </CardActions >
+                </Card>
+            </Popover>
+        </>;
+    }
+
+    static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
+        return props.filters !== state.prevFilters
+            ? { ...state, filters: props.filters, prevFilters: props.filters }
+            : state;
+    }
+
+    open = () => {
+        this.setState({ anchorEl: this.icon.current || undefined });
+    }
+
+    submit = () => {
+        const { onChange } = this.props;
+        if (onChange) {
+            onChange(this.state.filters);
+        }
+        this.setState({ anchorEl: undefined });
+    }
+
+    cancel = () => {
+        this.setState(prev => ({
+            ...prev,
+            filters: prev.prevFilters,
+            anchorEl: undefined
+        }));
+    }
+
+    toggleFilter = (toggledFilter: DataTableFilterItem) => () => {
+        this.setState(prev => ({
+            ...prev,
+            filters: prev.filters.map(filter =>
+                filter === toggledFilter
+                    ? { ...filter, selected: !filter.selected }
+                    : filter)
+        }));
+    }
+}
+
+
+export type CssRules = "root" | "icon" | "active" | "checkbox";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        cursor: "pointer",
+        display: "inline-flex",
+        justifyContent: "flex-start",
+        flexDirection: "inherit",
+        alignItems: "center",
+        "&:hover": {
+            color: theme.palette.text.primary,
+        },
+        "&:focus": {
+            color: theme.palette.text.primary,
+        },
+    },
+    active: {
+        color: theme.palette.text.primary,
+        '& $icon': {
+            opacity: 1,
+        },
+    },
+    icon: {
+        marginRight: 4,
+        marginLeft: 4,
+        opacity: 0.7,
+        userSelect: "none",
+        width: 16
+    },
+    checkbox: {
+        width: 24,
+        height: 24
+    }
+});
+
+export default withStyles(styles)(DataTableFilter);
diff --git a/src/components/data-table/data-column.ts b/src/components/data-table/data-column.ts
new file mode 100644 (file)
index 0000000..1ef7d98
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
+
+export interface DataColumn<T> {
+    name: string;
+    selected: boolean;
+    configurable?: boolean;
+    key?: React.Key;
+    sortDirection?: SortDirection;
+    filters?: DataTableFilterItem[];
+    render: (item: T) => React.ReactElement<void>;
+    renderHeader?: () => React.ReactElement<void> | null;
+}
+
+export type SortDirection = "asc" | "desc" | "none";
+
+export const isColumnConfigurable = <T>(column: DataColumn<T>) => {
+    return column.configurable === undefined || column.configurable;
+};
+
+export const toggleSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
+    return column.sortDirection
+        ? column.sortDirection === "asc"
+            ? { ...column, sortDirection: "desc" }
+            : { ...column, sortDirection: "asc" }
+        : column;
+};
+
+export const resetSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
+    return column.sortDirection ? { ...column, sortDirection: "none" } : column;
+};
diff --git a/src/components/data-table/data-table.test.tsx b/src/components/data-table/data-table.test.tsx
new file mode 100644 (file)
index 0000000..439e6c2
--- /dev/null
@@ -0,0 +1,185 @@
+// 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 { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
+import * as Adapter from "enzyme-adapter-react-16";
+import DataTable from "./data-table";
+import { DataColumn } from "./data-column";
+import DataTableFilters from "../data-table-filters/data-table-filters";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTable />", () => {
+    it("shows only selected columns", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: false
+            }
+        ];
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={["item 1"]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
+    });
+
+    it("renders column name", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={["item 1"]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
+    });
+
+    it("uses renderHeader instead of name prop", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                renderHeader: () => <span>Column Header</span>,
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={["item 1"]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
+    });
+
+    it("passes column key prop to corresponding cells", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                key: "column-1-key",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={["item 1"]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
+        expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
+    });
+
+    it("shows information that items array is empty", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={[]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(Typography).text()).toBe("No items");
+    });
+
+    it("renders items", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: (item) => <Typography>{item}</Typography>,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: (item) => <Button>{item}</Button>,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable 
+            columns={columns} 
+            items={["item 1"]}
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()} />);
+        expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
+        expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
+    });
+
+    it("passes sorting props to <TableSortLabel />", () => {
+        const columns: Array<DataColumn<string>> = [{
+            name: "Column 1",
+            sortDirection: "asc",
+            selected: true,
+            render: (item) => <Typography>{item}</Typography>
+        }];
+        const onSortToggle = jest.fn();
+        const dataTable = mount(<DataTable 
+            columns={columns} 
+            items={["item 1"]} 
+            onFiltersChange={jest.fn()}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={onSortToggle}/>);
+        expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
+        dataTable.find(TableSortLabel).at(0).simulate("click");
+        expect(onSortToggle).toHaveBeenCalledWith(columns[0]);
+    });
+
+    it("passes filter props to <DataTableFilter />", () => {
+        const columns: Array<DataColumn<string>> = [{
+            name: "Column 1",
+            sortDirection: "asc",
+            selected: true,
+            filters: [{name: "Filter 1", selected: true}],
+            render: (item) => <Typography>{item}</Typography>
+        }];
+        const onFiltersChange = jest.fn();
+        const dataTable = mount(<DataTable 
+            columns={columns} 
+            items={["item 1"]} 
+            onFiltersChange={onFiltersChange}
+            onRowClick={jest.fn()}
+            onRowContextMenu={jest.fn()}
+            onSortToggle={jest.fn()}/>);
+        expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters);
+        dataTable.find(DataTableFilters).prop("onChange")([]);
+        expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
+    });
+
+
+});
\ No newline at end of file
diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx
new file mode 100644 (file)
index 0000000..e86113e
--- /dev/null
@@ -0,0 +1,110 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, Typography } from '@material-ui/core';
+import { DataColumn, SortDirection } from './data-column';
+import DataTableFilters, { DataTableFilterItem } from "../data-table-filters/data-table-filters";
+
+export type DataColumns<T> = Array<DataColumn<T>>;
+
+export interface DataTableProps<T> {
+    items: T[];
+    columns: DataColumns<T>;
+    onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+    onRowContextMenu: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+    onSortToggle: (column: DataColumn<T>) => void;
+    onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
+}
+
+class DataTable<T> extends React.Component<DataTableProps<T> & WithStyles<CssRules>> {
+    render() {
+        const { items, classes } = this.props;
+        return <div className={classes.tableContainer}>
+            {items.length > 0 ?
+                <Table>
+                    <TableHead>
+                        <TableRow>
+                            {this.mapVisibleColumns(this.renderHeadCell)}
+                        </TableRow>
+                    </TableHead>
+                    <TableBody className={classes.tableBody}>
+                        {items.map(this.renderBodyRow)}
+                    </TableBody>
+                </Table> : <Typography
+                    className={classes.noItemsInfo}
+                    variant="body2"
+                    gutterBottom>
+                    No items
+                </Typography>}
+        </div>;
+    }
+
+    renderHeadCell = (column: DataColumn<T>, index: number) => {
+        const { name, key, renderHeader, filters, sortDirection } = column;
+        const { onSortToggle, onFiltersChange } = this.props;
+        return <TableCell key={key || index}>
+            {renderHeader ?
+                renderHeader() :
+                filters
+                    ? <DataTableFilters
+                        name={`${name} filters`}
+                        onChange={filters =>
+                            onFiltersChange &&
+                            onFiltersChange(filters, column)}
+                        filters={filters}>
+                        {name}
+                    </DataTableFilters>
+                    : sortDirection
+                        ? <TableSortLabel
+                            active={sortDirection !== "none"}
+                            direction={sortDirection !== "none" ? sortDirection : undefined}
+                            onClick={() =>
+                                onSortToggle &&
+                                onSortToggle(column)}>
+                            {name}
+                        </TableSortLabel>
+                        : <span>
+                            {name}
+                        </span>}
+        </TableCell>;
+    }
+
+    renderBodyRow = (item: T, index: number) => {
+        const { columns, onRowClick, onRowContextMenu } = this.props;
+        return <TableRow
+            hover
+            key={index}
+            onClick={event => onRowClick && onRowClick(event, item)}
+            onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}>
+            {this.mapVisibleColumns((column, index) => (
+                <TableCell key={column.key || index}>
+                    {column.render(item)}
+                </TableCell>
+            ))}
+        </TableRow>;
+    }
+
+    mapVisibleColumns = (fn: (column: DataColumn<T>, index: number) => React.ReactElement<any>) => {
+        return this.props.columns.filter(column => column.selected).map(fn);
+    }
+
+}
+
+type CssRules = "tableBody" | "tableContainer" | "noItemsInfo";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    tableContainer: {
+        overflowX: 'auto'
+    },
+    tableBody: {
+        background: theme.palette.background.paper
+    },
+    noItemsInfo: {
+        textAlign: "center",
+        padding: theme.spacing.unit
+    }
+});
+
+export default withStyles(styles)(DataTable);
diff --git a/src/components/popover/helpers.ts b/src/components/popover/helpers.ts
new file mode 100644 (file)
index 0000000..13f74a6
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PopoverOrigin } from "@material-ui/core/Popover";
+
+export const mockAnchorFromMouseEvent = (event: React.MouseEvent<HTMLElement>) => {
+    const el = document.createElement('div');
+    const clientRect = {
+        left: event.clientX,
+        right: event.clientX,
+        top: event.clientY,
+        bottom: event.clientY,
+        width: 0,
+        height: 0
+    };
+    el.getBoundingClientRect = () => clientRect;
+    return el;
+};
+
+export const DefaultTransformOrigin: PopoverOrigin = {
+    vertical: "top",
+    horizontal: "right",
+};
\ No newline at end of file
diff --git a/src/components/popover/popover.test.tsx b/src/components/popover/popover.test.tsx
new file mode 100644 (file)
index 0000000..fa24c0c
--- /dev/null
@@ -0,0 +1,69 @@
+// 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 Popover, { DefaultTrigger } from "./popover";
+import Button, { ButtonProps } from "@material-ui/core/Button";
+
+configure({ adapter: new Adapter() });
+
+describe("<Popover />", () => {
+    it("opens on default trigger click", () => {
+        const popover = mount(<Popover />);
+        popover.find(DefaultTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+
+    it("renders custom trigger", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        expect(popover.find(Button).text()).toBe("Open popover");
+    });
+
+    it("opens on custom trigger click", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+
+    it("renders children when opened", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        expect(popover.find(CustomTrigger)).toHaveLength(1);
+    });
+    
+    it("does not close if closeOnContentClick is not set", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+    it("closes on content click if closeOnContentClick is set", () => {
+        const popover = mount(
+            <Popover closeOnContentClick>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeUndefined();
+    });
+
+});
+
+const CustomTrigger: React.SFC<ButtonProps> = (props) => (
+    <Button {...props}>
+        Open popover
+    </Button>
+);
\ No newline at end of file
diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx
new file mode 100644 (file)
index 0000000..c8d4033
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Popover as MaterialPopover } from '@material-ui/core';
+
+import { PopoverOrigin } from '@material-ui/core/Popover';
+import IconButton, { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface PopoverProps {
+    triggerComponent?: React.ComponentType<{ onClick: (event: React.MouseEvent<any>) => void }>;
+    closeOnContentClick?: boolean;
+}
+
+
+class Popover extends React.Component<PopoverProps> {
+
+    state = {
+        anchorEl: undefined
+    };
+
+    transformOrigin: PopoverOrigin = {
+        vertical: "top",
+        horizontal: "right",
+    };
+
+    render() {
+        const Trigger = this.props.triggerComponent || DefaultTrigger;
+        return (
+            <>
+                <Trigger onClick={this.handleTriggerClick} />
+                <MaterialPopover
+                    anchorEl={this.state.anchorEl}
+                    open={Boolean(this.state.anchorEl)}
+                    onClose={this.handleClose}
+                    onClick={this.handleSelfClick}
+                    transformOrigin={this.transformOrigin}
+                    anchorOrigin={this.transformOrigin}
+                >
+                    {this.props.children}
+                </MaterialPopover>
+            </>
+        );
+    }
+
+    handleClose = () => {
+        this.setState({ anchorEl: undefined });
+    }
+
+    handleTriggerClick = (event: React.MouseEvent<any>) => {
+        this.setState({ anchorEl: event.currentTarget });
+    }
+
+    handleSelfClick = () => {
+        if (this.props.closeOnContentClick) {
+            this.handleClose();
+        }
+    }
+
+}
+
+export const DefaultTrigger: React.SFC<IconButtonProps> = (props) => (
+    <IconButton {...props}>
+        <i className="fas" />
+    </IconButton>
+);
+
+export default Popover;
diff --git a/src/components/search-input/search-input.test.tsx b/src/components/search-input/search-input.test.tsx
new file mode 100644 (file)
index 0000000..b07445a
--- /dev/null
@@ -0,0 +1,99 @@
+// 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 SearchInput, { DEFAULT_SEARCH_DEBOUNCE } from "./search-input";
+
+import * as Adapter from 'enzyme-adapter-react-16';
+
+configure({ adapter: new Adapter() });
+
+describe("<SearchInput />", () => {
+
+    jest.useFakeTimers();
+
+    let onSearch: () => void;
+
+    beforeEach(() => {
+        onSearch = jest.fn();
+    });
+
+    describe("on submit", () => {
+        it("calls onSearch with initial value passed via props", () => {
+            const searchInput = mount(<SearchInput value="initial value" onSearch={onSearch} />);
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("initial value");
+        });
+
+        it("calls onSearch with current value", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+        it("calls onSearch with new value passed via props", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.setProps({value: "new value"});
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("new value");
+        });
+
+        it("cancels timeout set on input value change", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+    });
+
+    describe("on input value change", () => {
+        it("calls onSearch after default timeout", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            expect(onSearch).not.toBeCalled();
+            jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+        
+        it("calls onSearch after the time specified in props has passed", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={2000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).not.toBeCalled();
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+        
+        it("calls onSearch only once after no change happened during the specified time", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "changed value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+        });
+        
+        it("calls onSearch again after the specified time has passed since previous call", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "intermediate value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("intermediate value");
+            searchInput.find("input").simulate("change", { target: { value: "latest value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("latest value");
+            expect(onSearch).toHaveBeenCalledTimes(2);
+            
+        });
+
+    });
+
+});
\ No newline at end of file
diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx
new file mode 100644 (file)
index 0000000..edc82d5
--- /dev/null
@@ -0,0 +1,113 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, FormHelperText } from '@material-ui/core';
+import SearchIcon from '@material-ui/icons/Search';
+
+interface SearchInputDataProps {
+    value: string;
+}
+
+interface SearchInputActionProps {
+    onSearch: (value: string) => any;
+    debounce?: number;
+}
+
+type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles<CssRules>;
+
+interface SearchInputState {
+    value: string;
+}
+
+export const DEFAULT_SEARCH_DEBOUNCE = 1000;
+
+class SearchInput extends React.Component<SearchInputProps> {
+
+    state: SearchInputState = {
+        value: ""
+    };
+
+    timeout: number;
+
+    render() {
+        const { classes } = this.props;
+        return <form onSubmit={this.handleSubmit}>
+            <FormControl>
+                <InputLabel>Search</InputLabel>
+                <Input
+                    type="text"
+                    value={this.state.value}
+                    onChange={this.handleChange}
+                    endAdornment={
+                        <InputAdornment position="end">
+                            <IconButton
+                                onClick={this.handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </InputAdornment>
+                    } />
+            </FormControl>
+        </form>;
+    }
+
+    componentDidMount() {
+        this.setState({ value: this.props.value });
+    }
+
+    componentWillReceiveProps(nextProps: SearchInputProps) {
+        if (nextProps.value !== this.props.value) {
+            this.setState({ value: nextProps.value });
+        }
+    }
+
+    componentWillUnmount() {
+        clearTimeout(this.timeout);
+    }
+
+    handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(this.timeout);
+        this.props.onSearch(this.state.value);
+    }
+
+    handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        clearTimeout(this.timeout);
+        this.setState({ value: event.target.value });
+        this.timeout = window.setTimeout(
+            () => this.props.onSearch(this.state.value),
+            this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        );
+
+    }
+
+}
+
+type CssRules = 'container' | 'input' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+    return {
+        container: {
+            position: 'relative',
+            width: '100%'
+        },
+        input: {
+            border: 'none',
+            borderRadius: theme.spacing.unit / 4,
+            boxSizing: 'border-box',
+            padding: theme.spacing.unit,
+            paddingRight: theme.spacing.unit * 4,
+            width: '100%',
+        },
+        button: {
+            position: 'absolute',
+            top: theme.spacing.unit / 2,
+            right: theme.spacing.unit / 2,
+            width: theme.spacing.unit * 3,
+            height: theme.spacing.unit * 3
+        }
+    };
+};
+
+export default withStyles(styles)(SearchInput);
\ No newline at end of file
index 5dd45878dbe2342dff788bf499bec6991d6c8c6d..2c19a831ecd1154bfe7de3746787a6b3d6641eb3 100644 (file)
@@ -30,7 +30,7 @@ interface TreeProps<T> {
     items?: Array<TreeItem<T>>;
     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
     toggleItemOpen: (id: string, status: TreeItemStatus) => void;
-    toggleItemActive: (id: string) => void;
+    toggleItemActive: (id: string, status: TreeItemStatus) => void;
     level?: number;
 }
 
@@ -50,7 +50,7 @@ class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
         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 }} onClick={() => toggleItemActive(it.id)}>
+                    <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)}>
                         {it.status === TreeItemStatus.Pending ? <CircularProgress size={10} className={loader} /> : null}
                         {it.toggled && it.items && it.items.length === 0 ? null : this.renderArrow(it.status, it.active ? activeArrow : inactiveArrow, it.open, it.id)}
                         {render(it, level)}
index 1807bd8dc199cd4e5e2522f67aa81eff4a6914f3..ba395e8b785ab49dd6255427ff90382b43ff6191 100644 (file)
@@ -11,10 +11,10 @@ import { Route } from "react-router";
 import createBrowserHistory from "history/createBrowserHistory";
 import configureStore from "./store/store";
 import { ConnectedRouter } from "react-router-redux";
-import ApiToken from "./components/api-token/api-token";
+import ApiToken from "./views-components/api-token/api-token";
 import authActions from "./store/auth/auth-action";
-import { authService, projectService } from "./services/services";
-import { sidePanelData } from './store/side-panel/side-panel-reducer';
+import { authService } from "./services/services";
+import { getProjectList } from "./store/project/project-action";
 
 const history = createBrowserHistory();
 
@@ -32,7 +32,7 @@ const store = configureStore({
 
 store.dispatch(authActions.INIT());
 const rootUuid = authService.getRootUuid();
-store.dispatch<any>(projectService.getProjectList(rootUuid));
+store.dispatch<any>(getProjectList(rootUuid));
 
 const App = () =>
     <Provider store={store}>
diff --git a/src/models/collection.ts b/src/models/collection.ts
new file mode 100644 (file)
index 0000000..316b1fa
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "./resource";
+
+export interface Collection extends Resource {
+}
index 83fb59bd3eb0b4f77854848a3637488ce9894eb2..7d29de872974c62e5c7f9ce6fec6c3b591a1d5e6 100644 (file)
@@ -2,11 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-export interface Project {
-    name: string;
-    createdAt: string;
-    modifiedAt: string;
-    uuid: string;
-    ownerUuid: string;
-    href: string;
+import { Resource } from "./resource";
+
+export interface Project extends Resource {
 }
diff --git a/src/models/resource.ts b/src/models/resource.ts
new file mode 100644 (file)
index 0000000..39b4e91
--- /dev/null
@@ -0,0 +1,9 @@
+export interface Resource {
+    name: string;
+    createdAt: string;
+    modifiedAt: string;
+    uuid: string;
+    ownerUuid: string;
+    href: string;
+    kind: string;
+}
index 5878dc6ed5f01e9dc5657d122d41239fc005554d..d71f0299aa6cd866d30da96dd59fd69e17b94f03 100644 (file)
@@ -4,8 +4,6 @@
 
 import { API_HOST, serverApi } from "../../common/api/server-api";
 import { User } from "../../models/user";
-import { Dispatch } from "redux";
-import actions from "../../store/auth/auth-action";
 
 export const API_TOKEN_KEY = 'apiToken';
 export const USER_EMAIL_KEY = 'userEmail';
@@ -79,13 +77,16 @@ export default class AuthService {
         window.location.assign(`${API_HOST}/logout?return_to=${currentUrl}`);
     }
 
-    public getUserDetails = () => (dispatch: Dispatch): Promise<void> => {
-        dispatch(actions.USER_DETAILS_REQUEST());
+    public getUserDetails = (): Promise<User> => {
         return serverApi
             .get<UserDetailsResponse>('/users/current')
-            .then(resp => {
-                dispatch(actions.USER_DETAILS_SUCCESS(resp.data));
-            });
+            .then(resp => ({
+                email: resp.data.email,
+                firstName: resp.data.first_name,
+                lastName: resp.data.last_name,
+                uuid: resp.data.uuid,
+                ownerUuid: resp.data.owner_uuid
+            }));
     }
 
     public getRootUuid() {
diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts
new file mode 100644 (file)
index 0000000..171cd85
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { serverApi } from "../../common/api/server-api";
+import FilterBuilder, { FilterField } from "../../common/api/filter-builder";
+import { ArvadosResource } from "../response";
+import { Collection } from "../../models/collection";
+
+interface CollectionResource extends ArvadosResource {
+    name: string;
+    description: string;
+    properties: any;
+    portable_data_hash: string;
+    manifest_text: string;
+    replication_desired: number;
+    replication_confirmed: number;
+    replication_confirmed_at: string;
+    trash_at: string;
+    delete_at: string;
+    is_trashed: boolean;
+}
+
+interface CollectionsResponse {
+    offset: number;
+    limit: number;
+    items: CollectionResource[];
+}
+
+export default class CollectionService {
+    public getCollectionList = (parentUuid?: string): Promise<Collection[]> => {
+        if (parentUuid) {
+            const fb = new FilterBuilder();
+            fb.addLike(FilterField.OWNER_UUID, parentUuid);
+            return serverApi.get<CollectionsResponse>('/collections', { params: {
+                filters: fb.get()
+            }}).then(resp => {
+                const collections = resp.data.items.map(g => ({
+                    name: g.name,
+                    createdAt: g.created_at,
+                    modifiedAt: g.modified_at,
+                    href: g.href,
+                    uuid: g.uuid,
+                    ownerUuid: g.owner_uuid,
+                    kind: g.kind
+                } as Collection));
+                return collections;
+            });
+        } else {
+            return Promise.resolve([]);
+        }
+    }
+}
index bb3d0713f82efaee3f4b082b55d926a77bf47bba..bc34081811fdbbdd6aaa14437087ab82549a08fa 100644 (file)
@@ -4,56 +4,46 @@
 
 import { serverApi } from "../../common/api/server-api";
 import { Dispatch } from "redux";
-import actions from "../../store/project/project-action";
 import { Project } from "../../models/project";
-import UrlBuilder from "../../common/api/url-builder";
 import FilterBuilder, { FilterField } from "../../common/api/filter-builder";
+import { ArvadosResource } from "../response";
+
+interface GroupResource extends ArvadosResource {
+    name: string;
+    group_class: string;
+    description: string;
+    writable_by: string[];
+    delete_at: string;
+    trash_at: string;
+    is_trashed: boolean;
+}
 
 interface GroupsResponse {
     offset: number;
     limit: number;
-    items: Array<{
-        href: string;
-        kind: string;
-        etag: string;
-        uuid: string;
-        owner_uuid: string;
-        created_at: string;
-        modified_by_client_uuid: string;
-        modified_by_user_uuid: string;
-        modified_at: string;
-        name: string;
-        group_class: string;
-        description: string;
-        writable_by: string[];
-        delete_at: string;
-        trash_at: string;
-        is_trashed: boolean;
-    }>;
+    items: GroupResource[];
 }
 
 export default class ProjectService {
-    public getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Project[]> => {
-        dispatch(actions.PROJECTS_REQUEST(parentUuid));
+    public getProjectList = (parentUuid?: string): Promise<Project[]> => {
         if (parentUuid) {
             const fb = new FilterBuilder();
             fb.addLike(FilterField.OWNER_UUID, parentUuid);
             return serverApi.get<GroupsResponse>('/groups', { params: {
                 filters: fb.get()
-            }}).then(groups => {
-                const projects = groups.data.items.map(g => ({
+            }}).then(resp => {
+                const projects = resp.data.items.map(g => ({
                     name: g.name,
                     createdAt: g.created_at,
                     modifiedAt: g.modified_at,
                     href: g.href,
                     uuid: g.uuid,
-                    ownerUuid: g.owner_uuid
+                    ownerUuid: g.owner_uuid,
+                    kind: g.kind
                 } as Project));
-                dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid}));
                 return projects;
             });
         } else {
-            dispatch(actions.PROJECTS_SUCCESS({projects: [], parentItemId: parentUuid}));
             return Promise.resolve([]);
         }
     }
diff --git a/src/services/response.ts b/src/services/response.ts
new file mode 100644 (file)
index 0000000..a71282b
--- /dev/null
@@ -0,0 +1,11 @@
+export interface ArvadosResource {
+    uuid: string;
+    owner_uuid: string;
+    created_at: string;
+    modified_by_client_uuid: string;
+    modified_by_user_uuid: string;
+    modified_at: string;
+    href: string;
+    kind: string;
+    etag: string;
+}
index ea72001a2849bd983aabbe1c16067c4166b46690..47a24b344aaa7d43c0f257d3c47aabd526995368 100644 (file)
@@ -4,6 +4,8 @@
 
 import AuthService from "./auth-service/auth-service";
 import ProjectService from "./project-service/project-service";
+import CollectionService from "./collection-service/collection-service";
 
 export const authService = new AuthService();
 export const projectService = new ProjectService();
+export const collectionService = new CollectionService();
index e18c78b106f8578b2d396127c74f486f3d62d2ff..a6e6f79794db27bc64178b6f53626c6e688c3865 100644 (file)
@@ -3,7 +3,9 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ofType, default as unionize, UnionOf } from "unionize";
-import { UserDetailsResponse } from "../../services/auth-service/auth-service";
+import { Dispatch } from "redux";
+import { authService } from "../../services/services";
+import { User } from "../../models/user";
 
 const actions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
@@ -11,11 +13,20 @@ const actions = unionize({
     LOGOUT: {},
     INIT: {},
     USER_DETAILS_REQUEST: {},
-    USER_DETAILS_SUCCESS: ofType<UserDetailsResponse>()
+    USER_DETAILS_SUCCESS: ofType<User>()
 }, {
     tag: 'type',
     value: 'payload'
 });
 
+export const getUserDetails = () => (dispatch: Dispatch): Promise<User> => {
+    dispatch(actions.USER_DETAILS_REQUEST());
+    return authService.getUserDetails().then(details => {
+        dispatch(actions.USER_DETAILS_SUCCESS(details));
+        return details;
+    });
+};
+
+
 export type AuthAction = UnionOf<typeof actions>;
 export default actions;
index a60e82a6c8bde1d4f326ba341dd5f908119a49ff..2e7c1a248800f94cf5bfdbe03c07e9e2f980d093 100644 (file)
@@ -68,16 +68,15 @@ describe('auth-reducer', () => {
     it('should set user details on success fetch', () => {
         const initialState = undefined;
 
-        const userDetails = {
+        const user = {
             email: "test@test.com",
-            first_name: "John",
-            last_name: "Doe",
+            firstName: "John",
+            lastName: "Doe",
             uuid: "uuid",
-            owner_uuid: "ownerUuid",
-            is_admin: true
+            ownerUuid: "ownerUuid"
         };
 
-        const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(userDetails));
+        const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(user));
         expect(state).toEqual({
             apiToken: undefined,
             user: {
index 02b9d30c30dcf422cb7ab631186654de76f6ccdc..f6974fd2073be49a5c01fe6677bff22edbc4ae8d 100644 (file)
@@ -6,7 +6,6 @@ import actions, { AuthAction } from "./auth-action";
 import { User } from "../../models/user";
 import { authService } from "../../services/services";
 import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api";
-import { UserDetailsResponse } from "../../services/auth-service/auth-service";
 
 export interface AuthState {
     user?: User;
@@ -39,14 +38,7 @@ const authReducer = (state: AuthState = {}, action: AuthAction) => {
             authService.logout();
             return {...state, apiToken: undefined};
         },
-        USER_DETAILS_SUCCESS: (ud: UserDetailsResponse) => {
-            const user = {
-                email: ud.email,
-                firstName: ud.first_name,
-                lastName: ud.last_name,
-                uuid: ud.uuid,
-                ownerUuid: ud.owner_uuid
-            };
+        USER_DETAILS_SUCCESS: (user: User) => {
             authService.saveUser(user);
             return {...state, user};
         },
diff --git a/src/store/collection/collection-action.ts b/src/store/collection/collection-action.ts
new file mode 100644 (file)
index 0000000..f50e645
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Collection } from "../../models/collection";
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+import { collectionService } from "../../services/services";
+
+const actions = unionize({
+    CREATE_COLLECTION: ofType<Collection>(),
+    REMOVE_COLLECTION: ofType<string>(),
+    COLLECTIONS_REQUEST: ofType<any>(),
+    COLLECTIONS_SUCCESS: ofType<{ collections: Collection[] }>(),
+}, {
+    tag: 'type',
+    value: 'payload'
+});
+
+export const getCollectionList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Collection[]> => {
+    dispatch(actions.COLLECTIONS_REQUEST());
+    return collectionService.getCollectionList(parentUuid).then(collections => {
+        dispatch(actions.COLLECTIONS_SUCCESS({collections}));
+        return collections;
+    });
+};
+
+export type CollectionAction = UnionOf<typeof actions>;
+export default actions;
diff --git a/src/store/collection/collection-reducer.test.ts b/src/store/collection/collection-reducer.test.ts
new file mode 100644 (file)
index 0000000..7b57ba7
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import collectionsReducer from "./collection-reducer";
+import actions from "./collection-action";
+
+describe('collection-reducer', () => {
+    it('should add new collection to the list', () => {
+        const initialState = undefined;
+        const collection = {
+            name: 'test',
+            href: 'href',
+            createdAt: '2018-01-01',
+            modifiedAt: '2018-01-01',
+            ownerUuid: 'owner-test123',
+            uuid: 'test123',
+            kind: ""
+        };
+
+        const state = collectionsReducer(initialState, actions.CREATE_COLLECTION(collection));
+        expect(state).toEqual([collection]);
+    });
+
+    it('should load collections', () => {
+        const initialState = undefined;
+        const collection = {
+            name: 'test',
+            href: 'href',
+            createdAt: '2018-01-01',
+            modifiedAt: '2018-01-01',
+            ownerUuid: 'owner-test123',
+            uuid: 'test123',
+            kind: ""
+        };
+
+        const collections = [collection, collection];
+        const state = collectionsReducer(initialState, actions.COLLECTIONS_SUCCESS({ collections }));
+        expect(state).toEqual([collection, collection]);
+    });
+});
diff --git a/src/store/collection/collection-reducer.ts b/src/store/collection/collection-reducer.ts
new file mode 100644 (file)
index 0000000..939ca62
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import actions, { CollectionAction } from "./collection-action";
+import { Collection } from "../../models/collection";
+
+export type CollectionState = Collection[];
+
+
+const collectionsReducer = (state: CollectionState = [], action: CollectionAction) => {
+    return actions.match(action, {
+        CREATE_COLLECTION: collection => [...state, collection],
+        REMOVE_COLLECTION: () => state,
+        COLLECTIONS_REQUEST: () => {
+            return state;
+        },
+        COLLECTIONS_SUCCESS: ({ collections }) => {
+            return collections;
+        },
+        default: () => state
+    });
+};
+
+export default collectionsReducer;
index a58edd3caa994082e9d9431a718442a656435d46..3c264d3ef9fcbba5089a9294a65b3aec0d863c5b 100644 (file)
@@ -4,19 +4,31 @@
 import { default as unionize, ofType, UnionOf } from "unionize";
 
 import { Project } from "../../models/project";
+import { projectService } from "../../services/services";
+import { Dispatch } from "redux";
 
 const actions = unionize({
     CREATE_PROJECT: ofType<Project>(),
     REMOVE_PROJECT: ofType<string>(),
-    PROJECTS_REQUEST: ofType<any>(),
+    PROJECTS_REQUEST: ofType<string>(),
     PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(),
     TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType<string>(),
     TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType<string>(),
     RESET_PROJECT_TREE_ACTIVITY: ofType<string>(),
 }, {
-    tag: 'type',
-    value: 'payload'
-});
+        tag: 'type',
+        value: 'payload'
+    });
+
+export const getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Project[]> => {
+    if (parentUuid) {
+        dispatch(actions.PROJECTS_REQUEST(parentUuid));
+        return projectService.getProjectList(parentUuid).then(projects => {
+            dispatch(actions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
+            return projects;
+        });
+    } return Promise.resolve([]);
+};
 
 export type ProjectAction = UnionOf<typeof actions>;
 export default actions;
index e5cd57e29fe7223f980f69b6b6e1df30100d0a6b..e8d6afc6154dd004af9729042bbd9d48f7265bff 100644 (file)
@@ -2,8 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import projectsReducer from "./project-reducer";
+import projectsReducer, { getTreePath } from "./project-reducer";
 import actions from "./project-action";
+import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
 
 describe('project-reducer', () => {
     it('should add new project to the list', () => {
@@ -14,7 +15,8 @@ describe('project-reducer', () => {
             createdAt: '2018-01-01',
             modifiedAt: '2018-01-01',
             ownerUuid: 'owner-test123',
-            uuid: 'test123'
+            uuid: 'test123',
+            kind: ""
         };
 
         const state = projectsReducer(initialState, actions.CREATE_PROJECT(project));
@@ -29,7 +31,8 @@ describe('project-reducer', () => {
             createdAt: '2018-01-01',
             modifiedAt: '2018-01-01',
             ownerUuid: 'owner-test123',
-            uuid: 'test123'
+            uuid: 'test123',
+            kind: ""
         };
 
         const projects = [project, project];
@@ -62,6 +65,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: true,
@@ -78,6 +82,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: true,
@@ -100,6 +105,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: true,
@@ -116,6 +122,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: true,
@@ -139,6 +146,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: true,
@@ -156,6 +164,7 @@ describe('project-reducer', () => {
                     modifiedAt: '2018-01-01',
                     ownerUuid: 'owner-test123',
                     uuid: 'test123',
+                    kind: 'example'
                 },
                 id: "1",
                 open: false,
@@ -169,3 +178,53 @@ describe('project-reducer', () => {
         expect(state).toEqual(project);
     });
 });
+
+describe("findTreeBranch", () => {
+
+    const createTreeItem = (id: string, items?: Array<TreeItem<string>>): TreeItem<string> => ({
+        id,
+        items,
+        active: false,
+        data: "",
+        open: false,
+        status: TreeItemStatus.Initial
+    });
+
+    it("should return an array that matches path to the given item", () => {
+        const tree: Array<TreeItem<string>> = [
+            createTreeItem("1", [
+                createTreeItem("1.1", [
+                    createTreeItem("1.1.1"),
+                    createTreeItem("1.1.2")
+                ])
+            ]),
+            createTreeItem("2", [
+                createTreeItem("2.1", [
+                    createTreeItem("2.1.1"),
+                    createTreeItem("2.1.2")
+                ])
+            ])
+        ];
+        const branch = getTreePath(tree, "2.1.1");
+        expect(branch.map(item => item.id)).toEqual(["2", "2.1", "2.1.1"]);
+    });
+
+    it("should return empty array if item is not found", () => {
+        const tree: Array<TreeItem<string>> = [
+            createTreeItem("1", [
+                createTreeItem("1.1", [
+                    createTreeItem("1.1.1"),
+                    createTreeItem("1.1.2")
+                ])
+            ]),
+            createTreeItem("2", [
+                createTreeItem("2.1", [
+                    createTreeItem("2.1.1"),
+                    createTreeItem("2.1.2")
+                ])
+            ])
+        ];
+        expect(getTreePath(tree, "3")).toHaveLength(0);
+    });
+
+});
index 43117ef0a0e507234147cf2d999e6520108157b1..48db05df77eb6051fcc5c84509fe317777ad7f26 100644 (file)
@@ -10,7 +10,7 @@ import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
 
 export type ProjectState = Array<TreeItem<Project>>;
 
-function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
+export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
     let item;
     for (const t of tree) {
         item = t.id === itemId
@@ -23,6 +23,20 @@ function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T>
     return item;
 }
 
+export function getTreePath<T>(tree: Array<TreeItem<T>>, itemId: string): Array<TreeItem<T>> {
+    for(const item of tree){
+        if(item.id === itemId){
+            return [item];
+        } else {
+            const branch = getTreePath(item.items || [], itemId);
+            if(branch.length > 0){
+                return [item, ...branch];
+            }
+        }
+    }
+    return [];
+}
+
 function resetTreeActivity<T>(tree: Array<TreeItem<T>>) {
     for (const t of tree) {
         t.active = false;
index 49e0b5f7c5e48f2022b3fa3573ecbdde7de6e849..6089caf35cdf409d77ceb5ede5ced2ebc4083967 100644 (file)
@@ -10,6 +10,7 @@ import { History } from "history";
 import projectsReducer, { ProjectState } from "./project/project-reducer";
 import sidePanelReducer, { SidePanelState } from './side-panel/side-panel-reducer';
 import authReducer, { AuthState } from "./auth/auth-reducer";
+import collectionsReducer from "./collection/collection-reducer";
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -26,6 +27,7 @@ export interface RootState {
 const rootReducer = combineReducers({
     auth: authReducer,
     projects: projectsReducer,
+    collections: collectionsReducer,
     router: routerReducer,
     sidePanel: sidePanelReducer
 });
similarity index 77%
rename from src/components/api-token/api-token.tsx
rename to src/views-components/api-token/api-token.tsx
index 7656bf873368308f93947d943342d8de3c1471d5..e4ba4914a3518e95c557f3f93cc28293717dbd63 100644 (file)
@@ -5,8 +5,9 @@
 import { Redirect, RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
-import authActions from "../../store/auth/auth-action";
-import { authService, projectService } from "../../services/services";
+import authActions, { getUserDetails } from "../../store/auth/auth-action";
+import { authService } from "../../services/services";
+import { getProjectList } from "../../store/project/project-action";
 
 interface ApiTokenProps {
 }
@@ -23,9 +24,9 @@ class ApiToken extends React.Component<ApiTokenProps & RouteProps & DispatchProp
         const search = this.props.location ? this.props.location.search : "";
         const apiToken = ApiToken.getUrlParameter(search, 'api_token');
         this.props.dispatch(authActions.SAVE_API_TOKEN(apiToken));
-        this.props.dispatch<any>(authService.getUserDetails()).then(() => {
+        this.props.dispatch<any>(getUserDetails()).then(() => {
             const rootUuid = authService.getRootUuid();
-            this.props.dispatch(projectService.getProjectList(rootUuid));
+            this.props.dispatch(getProjectList(rootUuid));
         });
     }
     render() {
similarity index 95%
rename from src/components/main-app-bar/main-app-bar.test.tsx
rename to src/views-components/main-app-bar/main-app-bar.test.tsx
index f08c9392857e8ae37e4edb3a675f7d5e83cedfc2..25494b65a07c2e9c76757f84a25a26400a196ead 100644 (file)
@@ -6,9 +6,9 @@ import * as React from "react";
 import { mount, configure, ReactWrapper } from "enzyme";
 import * as Adapter from "enzyme-adapter-react-16";
 import MainAppBar from "./main-app-bar";
-import SearchBar from "./search-bar/search-bar";
-import Breadcrumbs from "../breadcrumbs/breadcrumbs";
-import DropdownMenu from "./dropdown-menu/dropdown-menu";
+import SearchBar from "../../components/search-bar/search-bar";
+import Breadcrumbs from "../../components/breadcrumbs/breadcrumbs";
+import DropdownMenu from "../../components/dropdown-menu/dropdown-menu";
 import { Button, MenuItem, IconButton } from "@material-ui/core";
 import { User } from "../../models/user";
 
@@ -98,4 +98,4 @@ describe("<MainAppBar />", () => {
         mainAppBar.find(DropdownMenu).at(0).find(MenuItem).at(1).simulate("click");
         expect(onMenuItemClick).toBeCalledWith(menuItems.accountMenu[0]);
     });
-});
\ No newline at end of file
+});
similarity index 94%
rename from src/components/main-app-bar/main-app-bar.tsx
rename to src/views-components/main-app-bar/main-app-bar.tsx
index 27cd8bd4d3df0b1196dd2fcb78c1ebee730a4689..c0525a566843e9a10f75b0a38fb84babef482c38 100644 (file)
@@ -7,9 +7,9 @@ import { AppBar, Toolbar, Typography, Grid, IconButton, Badge, StyleRulesCallbac
 import NotificationsIcon from "@material-ui/icons/Notifications";
 import PersonIcon from "@material-ui/icons/Person";
 import HelpIcon from "@material-ui/icons/Help";
-import SearchBar from "./search-bar/search-bar";
-import Breadcrumbs, { Breadcrumb } from "../breadcrumbs/breadcrumbs";
-import DropdownMenu from "./dropdown-menu/dropdown-menu";
+import SearchBar from "../../components/search-bar/search-bar";
+import Breadcrumbs, { Breadcrumb } from "../../components/breadcrumbs/breadcrumbs";
+import DropdownMenu from "../../components/dropdown-menu/dropdown-menu";
 import { User, getUserFullname } from "../../models/user";
 
 export interface MainAppBarMenuItem {
@@ -126,4 +126,4 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-export default withStyles(styles)(MainAppBar);
\ No newline at end of file
+export default withStyles(styles)(MainAppBar);
diff --git a/src/views-components/project-explorer/project-explorer-item.ts b/src/views-components/project-explorer/project-explorer-item.ts
new file mode 100644 (file)
index 0000000..055c22c
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface ProjectExplorerItem {
+    uuid: string;
+    name: string;
+    type: string;
+    owner: string;
+    lastModified: string;
+    fileSize?: number;
+    status?: string;
+}
diff --git a/src/views-components/project-explorer/project-explorer.tsx b/src/views-components/project-explorer/project-explorer.tsx
new file mode 100644 (file)
index 0000000..4931c09
--- /dev/null
@@ -0,0 +1,224 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { ProjectExplorerItem } from './project-explorer-item';
+import { Grid, Typography } from '@material-ui/core';
+import { formatDate, formatFileSize } from '../../common/formatters';
+import DataExplorer from '../../components/data-explorer/data-explorer';
+import { DataColumn, toggleSortDirection, resetSortDirection } from '../../components/data-table/data-column';
+import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
+import { ContextMenuAction } from '../../components/context-menu/context-menu';
+
+export interface ProjectExplorerContextActions {
+    onAddToFavourite: (item: ProjectExplorerItem) => void;
+    onCopy: (item: ProjectExplorerItem) => void;
+    onDownload: (item: ProjectExplorerItem) => void;
+    onMoveTo: (item: ProjectExplorerItem) => void;
+    onRemove: (item: ProjectExplorerItem) => void;
+    onRename: (item: ProjectExplorerItem) => void;
+    onShare: (item: ProjectExplorerItem) => void;
+}
+
+interface ProjectExplorerProps {
+    items: ProjectExplorerItem[];
+}
+
+interface ProjectExplorerState {
+    columns: Array<DataColumn<ProjectExplorerItem>>;
+    searchValue: string;
+    page: number;
+    rowsPerPage: number;
+}
+
+class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplorerState> {
+    state: ProjectExplorerState = {
+        searchValue: "",
+        page: 0,
+        rowsPerPage: 10,
+        columns: [{
+            name: "Name",
+            selected: true,
+            sortDirection: "asc",
+            render: renderName
+        }, {
+            name: "Status",
+            selected: true,
+            filters: [{
+                name: "In progress",
+                selected: true
+            }, {
+                name: "Complete",
+                selected: true
+            }],
+            render: renderStatus
+        }, {
+            name: "Type",
+            selected: true,
+            filters: [{
+                name: "Collection",
+                selected: true
+            }, {
+                name: "Group",
+                selected: true
+            }],
+            render: item => renderType(item.type)
+        }, {
+            name: "Owner",
+            selected: true,
+            render: item => renderOwner(item.owner)
+        }, {
+            name: "File size",
+            selected: true,
+            sortDirection: "none",
+            render: item => renderFileSize(item.fileSize)
+        }, {
+            name: "Last modified",
+            selected: true,
+            render: item => renderDate(item.lastModified)
+        }]
+    };
+
+    contextMenuActions = [[{
+        icon: "fas fa-users fa-fw",
+        name: "Share"
+    }, {
+        icon: "fas fa-sign-out-alt fa-fw",
+        name: "Move to"
+    }, {
+        icon: "fas fa-star fa-fw",
+        name: "Add to favourite"
+    }, {
+        icon: "fas fa-edit fa-fw",
+        name: "Rename"
+    }, {
+        icon: "fas fa-copy fa-fw",
+        name: "Make a copy"
+    }, {
+        icon: "fas fa-download fa-fw",
+        name: "Download"
+    }], [{
+        icon: "fas fa-trash-alt fa-fw",
+        name: "Remove"
+    }
+    ]];
+
+    render() {
+        return <DataExplorer
+            items={this.props.items}
+            columns={this.state.columns}
+            contextActions={this.contextMenuActions}
+            searchValue={this.state.searchValue}
+            page={this.state.page}
+            rowsPerPage={this.state.rowsPerPage}
+            onColumnToggle={this.toggleColumn}
+            onFiltersChange={this.changeFilters}
+            onRowClick={console.log}
+            onSortToggle={this.toggleSort}
+            onSearch={this.search}
+            onContextAction={this.executeAction}
+            onChangePage={this.changePage}
+            onChangeRowsPerPage={this.changeRowsPerPage} />;
+    }
+
+    toggleColumn = (toggledColumn: DataColumn<ProjectExplorerItem>) => {
+        this.setState({
+            columns: this.state.columns.map(column =>
+                column.name === toggledColumn.name
+                    ? { ...column, selected: !column.selected }
+                    : column
+            )
+        });
+    }
+
+    toggleSort = (toggledColumn: DataColumn<ProjectExplorerItem>) => {
+        this.setState({
+            columns: this.state.columns.map(column =>
+                column.name === toggledColumn.name
+                    ? toggleSortDirection(column)
+                    : resetSortDirection(column)
+            )
+        });
+    }
+
+    changeFilters = (filters: DataTableFilterItem[], updatedColumn: DataColumn<ProjectExplorerItem>) => {
+        this.setState({
+            columns: this.state.columns.map(column =>
+                column.name === updatedColumn.name
+                    ? { ...column, filters }
+                    : column
+            )
+        });
+    }
+
+    executeAction = (action: ContextMenuAction, item: ProjectExplorerItem) => {
+        alert(`Executing ${action.name} on ${item.name}`);
+    }
+
+    search = (searchValue: string) => {
+        this.setState({ searchValue });
+    }
+
+    changePage = (page: number) => {
+        this.setState({ page });
+    }
+
+    changeRowsPerPage = (rowsPerPage: number) => {
+        this.setState({ rowsPerPage });
+    }
+}
+
+const renderName = (item: ProjectExplorerItem) =>
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={16}>
+        <Grid item>
+            {renderIcon(item)}
+        </Grid>
+        <Grid item>
+            <Typography color="primary">
+                {item.name}
+            </Typography>
+        </Grid>
+    </Grid>;
+
+const renderIcon = (item: ProjectExplorerItem) => {
+    switch (item.type) {
+        case "arvados#group":
+            return <i className="fas fa-folder fa-lg" />;
+        case "arvados#groupList":
+            return <i className="fas fa-th fa-lg" />;
+        default:
+            return <i />;
+    }
+};
+
+const renderDate = (date: string) =>
+    <Typography noWrap>
+        {formatDate(date)}
+    </Typography>;
+
+const renderFileSize = (fileSize?: number) =>
+    <Typography noWrap>
+        {formatFileSize(fileSize)}
+    </Typography>;
+
+const renderOwner = (owner: string) =>
+    <Typography noWrap color="primary">
+        {owner}
+    </Typography>;
+
+const renderType = (type: string) =>
+    <Typography noWrap>
+        {type}
+    </Typography>;
+
+const renderStatus = (item: ProjectExplorerItem) =>
+    <Typography noWrap align="center">
+        {item.status || "-"}
+    </Typography>;
+
+export default ProjectExplorer;
similarity index 89%
rename from src/components/project-tree/project-tree.test.tsx
rename to src/views-components/project-tree/project-tree.test.tsx
index 932a29cc16793aede03e3dd035031cfa0a542d6b..1ba3abb8bb39ddb2c40de1098b769b35bd7ad105 100644 (file)
@@ -11,7 +11,7 @@ import { Collapse } from '@material-ui/core';
 import CircularProgress from '@material-ui/core/CircularProgress';
 
 import ProjectTree from './project-tree';
-import { TreeItem } from '../tree/tree';
+import { TreeItem } from '../../components/tree/tree';
 import { Project } from '../../models/project';
 Enzyme.configure({ adapter: new Adapter() });
 
@@ -26,13 +26,14 @@ describe("ProjectTree component", () => {
                 uuid: "uuid",
                 ownerUuid: "ownerUuid",
                 href: "href",
+                kind: 'example'
             },
             id: "3",
             open: true,
             active: true,
             status: 1
         };
-        const wrapper = mount(<ProjectTree projects={[project]} toggleProjectTreeItem={() => { }} />);
+        const wrapper = mount(<ProjectTree projects={[project]} toggleOpen={jest.fn()} toggleActive={jest.fn()} />);
 
         expect(wrapper.find(ListItemIcon)).toHaveLength(1);
     });
@@ -47,6 +48,7 @@ describe("ProjectTree component", () => {
                     uuid: "uuid",
                     ownerUuid: "ownerUuid",
                     href: "href",
+                    kind: 'example'
                 },
                 id: "3",
                 open: false,
@@ -61,6 +63,7 @@ describe("ProjectTree component", () => {
                     uuid: "uuid",
                     ownerUuid: "ownerUuid",
                     href: "href",
+                    kind: 'example'
                 },
                 id: "3",
                 open: false,
@@ -68,7 +71,7 @@ describe("ProjectTree component", () => {
                 status: 1
             }
         ];
-        const wrapper = mount(<ProjectTree projects={project} toggleProjectTreeItem={() => { }} />);
+        const wrapper = mount(<ProjectTree projects={project} toggleOpen={jest.fn()} toggleActive={jest.fn()} />);
 
         expect(wrapper.find(ListItemIcon)).toHaveLength(2);
     });
@@ -83,6 +86,7 @@ describe("ProjectTree component", () => {
                     uuid: "uuid",
                     ownerUuid: "ownerUuid",
                     href: "href",
+                    kind: 'example'
                 },
                 id: "3",
                 open: true,
@@ -97,6 +101,7 @@ describe("ProjectTree component", () => {
                             uuid: "uuid",
                             ownerUuid: "ownerUuid",
                             href: "href",
+                            kind: 'example'
                         },
                         id: "3",
                         open: true,
@@ -106,7 +111,7 @@ describe("ProjectTree component", () => {
                 ]
             }
         ];
-        const wrapper = mount(<ProjectTree projects={project} toggleProjectTreeItem={() => { }} />);
+        const wrapper = mount(<ProjectTree projects={project} toggleOpen={jest.fn()} toggleActive={jest.fn()}/>);
 
         expect(wrapper.find(Collapse)).toHaveLength(1);
     });
@@ -120,13 +125,14 @@ describe("ProjectTree component", () => {
                 uuid: "uuid",
                 ownerUuid: "ownerUuid",
                 href: "href",
+                kind: 'example'
             },
             id: "3",
             open: false,
             active: true,
             status: 1
         };
-        const wrapper = mount(<ProjectTree projects={[project]} toggleProjectTreeItem={() => { }} />);
+        const wrapper = mount(<ProjectTree projects={[project]} toggleOpen={jest.fn()} toggleActive={jest.fn()} />);
 
         expect(wrapper.find(CircularProgress)).toHaveLength(1);
     });
similarity index 94%
rename from src/components/project-tree/project-tree.tsx
rename to src/views-components/project-tree/project-tree.tsx
index 7406f7f3ec95651480a21a859b98f40d441c3e43..f51b65e054df7f8ab2d69a263e658f9a1fe2a7d8 100644 (file)
@@ -9,13 +9,13 @@ import ListItemText from "@material-ui/core/ListItemText/ListItemText";
 import ListItemIcon from '@material-ui/core/ListItemIcon';
 import Typography from '@material-ui/core/Typography';
 
-import Tree, { TreeItem, TreeItemStatus } from '../tree/tree';
+import Tree, { TreeItem, TreeItemStatus } from '../../components/tree/tree';
 import { Project } from '../../models/project';
 
 export interface ProjectTreeProps {
     projects: Array<TreeItem<Project>>;
     toggleOpen: (id: string, status: TreeItemStatus) => void;
-    toggleActive: (id: string) => void;
+    toggleActive: (id: string, status: TreeItemStatus) => void;
 }
 
 class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
diff --git a/src/views/project-panel/project-panel-selectors.ts b/src/views/project-panel/project-panel-selectors.ts
new file mode 100644 (file)
index 0000000..610f2fa
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { TreeItem } from "../../components/tree/tree";
+import { Project } from "../../models/project";
+import { ProjectExplorerItem } from "../../views-components/project-explorer/project-explorer-item";
+
+export const mapProjectTreeItem = (item: TreeItem<Project>): ProjectExplorerItem => ({
+    name: item.data.name,
+    type: item.data.kind,
+    owner: item.data.ownerUuid,
+    lastModified: item.data.modifiedAt,
+    uuid: item.data.uuid
+});
diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx
new file mode 100644 (file)
index 0000000..f9e6c8b
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { DispatchProp, connect } from 'react-redux';
+import { ProjectState, findTreeItem } from '../../store/project/project-reducer';
+import ProjectExplorer from '../../views-components/project-explorer/project-explorer';
+import { RootState } from '../../store/store';
+import { mapProjectTreeItem } from './project-panel-selectors';
+
+interface ProjectPanelDataProps {
+    projects: ProjectState;
+}
+
+type ProjectPanelProps = ProjectPanelDataProps & RouteComponentProps<{ name: string }> & DispatchProp;
+
+class ProjectPanel extends React.Component<ProjectPanelProps> {
+
+    render() {
+        const project = findTreeItem(this.props.projects, this.props.match.params.name);
+        const projectItems = project && project.items || [];
+        return (
+            <ProjectExplorer items={projectItems.map(mapProjectTreeItem)} />
+        );
+    }
+}
+
+export default connect(
+    (state: RootState) => ({
+        projects: state.projects
+    })
+)(ProjectPanel);
index 7b9b74d095c65a8a1ad760a2c4c87f360cb13836..6925792293b65c475539ddd9e0a9bf279cb02f9e 100644 (file)
@@ -14,7 +14,7 @@ const history = createBrowserHistory();
 
 it('renders without crashing', () => {
     const div = document.createElement('div');
-    const store = configureStore({ projects: [], router: { location: null }, auth: {} }, createBrowserHistory());
+    const store = configureStore({ projects: [], router: { location: null }, auth: {}, sidePanel: [] }, createBrowserHistory());
     ReactDOM.render(
         <Provider store={store}>
             <ConnectedRouter history={history}>
index 9e274325075d6923b9932ada253326e2a6c06c06..4f9843cb0a3e952786ef3489de8ac29b477796ee 100644 (file)
@@ -6,26 +6,27 @@ import * as React from 'react';
 import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles';
 import Drawer from '@material-ui/core/Drawer';
 import { connect, DispatchProp } from "react-redux";
-
-import ProjectList from "../../components/project-list/project-list";
 import { Route, Switch } from "react-router";
 import authActions from "../../store/auth/auth-action";
 import { User } from "../../models/user";
 import { RootState } from "../../store/store";
-import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
+import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../views-components/main-app-bar/main-app-bar';
 import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs';
 import { push } from 'react-router-redux';
-import projectActions from "../../store/project/project-action";
-import sidePanelActions from '../../store/side-panel/side-panel-action';
-import ProjectTree from '../../components/project-tree/project-tree';
+import projectActions, { getProjectList } from "../../store/project/project-action";
+import ProjectTree from '../../views-components/project-tree/project-tree';
 import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
 import { Project } from "../../models/project";
+import { getTreePath } from '../../store/project/project-reducer';
+import ProjectPanel from '../project-panel/project-panel';
+import sidePanelActions from '../../store/side-panel/side-panel-action';
 import { projectService } from '../../services/services';
 import SidePanel, { SidePanelItem } from '../../components/side-panel/side-panel';
 
 const drawerWidth = 240;
+const appBarHeight = 102;
 
-type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'toolbar';
+type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar';
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
@@ -49,12 +50,17 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         display: 'flex',
         flexDirection: 'column',
     },
-    content: {
-        flexGrow: 1,
+    contentWrapper: {
         backgroundColor: theme.palette.background.default,
-        padding: theme.spacing.unit * 3,
-        height: '100%',
+        display: "flex",
+        flexGrow: 1,
         minWidth: 0,
+        paddingTop: appBarHeight
+    },
+    content: {
+        padding: theme.spacing.unit * 3,
+        overflowY: "auto",
+        flexGrow: 1
     },
     toolbar: theme.mixins.toolbar
 });
@@ -71,7 +77,8 @@ interface WorkbenchActionProps {
 type WorkbenchProps = WorkbenchDataProps & WorkbenchActionProps & DispatchProp & WithStyles<CssRules>;
 
 interface NavBreadcrumb extends Breadcrumb {
-    path: string;
+    itemId: string;
+    status: TreeItemStatus;
 }
 
 interface NavMenuItem extends MainAppBarMenuItem {
@@ -93,15 +100,7 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
     state = {
         anchorEl: null,
         searchText: "",
-        breadcrumbs: [
-            {
-                label: "Projects",
-                path: "/projects"
-            }, {
-                label: "Project 1",
-                path: "/projects/project-1"
-            }
-        ],
+        breadcrumbs: [],
         menuItems: {
             accountMenu: [
                 {
@@ -130,7 +129,9 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
 
 
     mainAppBarActions: MainAppBarActionProps = {
-        onBreadcrumbClick: (breadcrumb: NavBreadcrumb) => this.props.dispatch(push(breadcrumb.path)),
+        onBreadcrumbClick: ({ itemId, status }: NavBreadcrumb) => {
+            this.toggleProjectTreeItemOpen(itemId, status);
+        },
         onSearch: searchText => {
             this.setState({ searchText });
             this.props.dispatch(push(`/search?q=${searchText}`));
@@ -140,19 +141,32 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
 
     toggleProjectTreeItemOpen = (itemId: string, status: TreeItemStatus) => {
         if (status === TreeItemStatus.Loaded) {
+            this.openProjectItem(itemId);
             this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
             this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
         } else {
-            this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => {
-                this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
-                this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
-            });
+            this.props.dispatch<any>(getProjectList(itemId))
+                .then(() => {
+                    this.openProjectItem(itemId);
+                    this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
+                    this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+                });
         }
     }
 
-    toggleProjectTreeItemActive = (itemId: string) => {
-        this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
-        this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+    toggleProjectTreeItemActive = (itemId: string, status: TreeItemStatus) => {
+        if (status === TreeItemStatus.Loaded) {
+            this.openProjectItem(itemId);
+            this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+            this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+        } else {
+            this.props.dispatch<any>(getProjectList(itemId))
+                .then(() => {
+                    this.openProjectItem(itemId);
+                    this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+                    this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+                });
+        }
     }
 
     toggleSidePanelOpen = (itemId: string) => {
@@ -164,6 +178,19 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
     }
 
+    openProjectItem = (itemId: string) => {
+        const branch = getTreePath(this.props.projects, itemId);
+        this.setState({
+            breadcrumbs: branch.map(item => ({
+                label: item.data.name,
+                itemId: item.data.uuid,
+                status: item.status
+            }))
+        });
+        this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+        this.props.dispatch(push(`/project/${itemId}`));
+    }
+
     render() {
         const { classes, user, projects, sidePanelItems } = this.props;
         return (
@@ -184,21 +211,22 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                             paper: classes.drawerPaper,
                         }}>
                         <div className={classes.toolbar} />
-                            <SidePanel
-                                toggleOpen={this.toggleSidePanelOpen}
-                                toggleActive={this.toggleSidePanelActive}
-                                sidePanelItems={sidePanelItems}>
-                                <ProjectTree
-                                    projects={projects}
-                                    toggleOpen={this.toggleProjectTreeItemOpen}
-                                    toggleActive={this.toggleProjectTreeItemActive} />
-                            </SidePanel>
+                        <SidePanel
+                            toggleOpen={this.toggleSidePanelOpen}
+                            toggleActive={this.toggleSidePanelActive}
+                            sidePanelItems={sidePanelItems}>
+                            <ProjectTree
+                                projects={projects}
+                                toggleOpen={this.toggleProjectTreeItemOpen}
+                                toggleActive={this.toggleProjectTreeItemActive} />
+                        </SidePanel>
                     </Drawer>}
-                <main className={classes.content}>
-                    <div className={classes.toolbar} />
-                    <Switch>
-                        <Route path="/project/:name" component={ProjectList} />
-                    </Switch>
+                <main className={classes.contentWrapper}>
+                    <div className={classes.content}>
+                        <Switch>
+                            <Route path="/project/:name" component={ProjectPanel} />
+                        </Switch>
+                    </div>
                 </main>
             </div>
         );
index eee6c8604b3bf2a54334135c3ef643abbb5037e8..9a3379b13a4082d1095a389ee2c1551c9bbff1ab 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   version "0.22.7"
   resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.7.tgz#4a92eafedfb2b9f4437d3a4410006d81114c66ce"
 
+"@types/classnames@^2.2.4":
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.4.tgz#d3ee9ebf714aa34006707b8f4a58fd46b642305a"
+
 "@types/enzyme-adapter-react-16@1.0.2":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.2.tgz#15ae37c64d6221a6f4b3a4aacc357cf773859de4"
   dependencies:
     "@types/react" "*"
 
-"@types/react@*", "@types/react@16.3.18":
+"@types/react@*", "@types/react@16.3":
   version "16.3.18"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.3.18.tgz#bf195aed4d77dc86f06e4c9bb760214a3b822b8d"
   dependencies:
@@ -1559,7 +1563,7 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-classnames@^2.2.5:
+classnames@^2.2.5, classnames@^2.2.6:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
 
@@ -2007,10 +2011,14 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
   dependencies:
     cssom "0.3.x"
 
-csstype@^2.0.0, csstype@^2.2.0, csstype@^2.5.2:
+csstype@^2.0.0, csstype@^2.5.2:
   version "2.5.3"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.3.tgz#2504152e6e1cc59b32098b7f5d6a63f16294c1f7"
 
+csstype@^2.2.0:
+  version "2.5.5"
+  resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.5.tgz#4125484a3d42189a863943f23b9e4b80fedfa106"
+
 currently-unhandled@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"