Merge branch 'origin/master' into 13666-data-explorer-mapper
authorDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 26 Jun 2018 06:09:29 +0000 (08:09 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 26 Jun 2018 06:09:29 +0000 (08:09 +0200)
Feature #13666

# Conflicts:
# src/components/tree/tree.tsx
# src/store/project/project-reducer.ts
# src/views-components/data-explorer/data-explorer.tsx
# src/views-components/project-explorer/project-explorer-item.ts
# src/views-components/project-tree/project-tree.tsx
# src/views/data-explorer/data-explorer-selectors.ts
# src/views/data-explorer/data-explorer.tsx
# src/views/workbench/workbench.tsx

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

34 files changed:
package.json
src/components/context-menu/context-menu.test.tsx
src/components/context-menu/context-menu.tsx
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
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
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/side-panel/side-panel.tsx [new file with mode: 0644]
src/components/tree/tree.tsx
src/index.tsx
src/store/navigation/navigation-action.ts
src/store/project/project-action.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/store/side-panel/side-panel-action.ts [new file with mode: 0644]
src/store/side-panel/side-panel-reducer.test.ts [new file with mode: 0644]
src/store/side-panel/side-panel-reducer.ts [new file with mode: 0644]
src/store/store.ts
src/views-components/data-explorer/data-explorer.tsx [deleted file]
src/views-components/project-explorer/project-explorer-item.ts [moved from src/views-components/data-explorer/data-item.ts with 93% similarity]
src/views-components/project-explorer/project-explorer.tsx [new file with mode: 0644]
src/views-components/project-tree/project-tree.test.tsx
src/views-components/project-tree/project-tree.tsx
src/views/data-explorer/data-explorer.tsx [deleted file]
src/views/project-panel/project-panel-selectors.ts [moved from src/views/data-explorer/data-explorer-selectors.ts with 84% similarity]
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 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",
index 9e4a9a4758a22c4c8aee29d794689327ba9826a8..e4e2397da280ae7ddfa20d9a7a6ed816c5c83c80 100644 (file)
@@ -11,41 +11,25 @@ import { ListItem } from "@material-ui/core";
 configure({ adapter: new Adapter() });
 
 describe("<ContextMenu />", () => {
-
-    const item = {
-        name: "",
-        owner: "",
-        lastModified: "",
-        type: ""
-    };
-
     const actions = [[{
         icon: "",
-        name: "Action 1.1",
-        onClick: jest.fn()
-    },
-    {
+        name: "Action 1.1"
+    }, {
         icon: "",
-        name: "Action 1.2",
-        onClick: jest.fn()
+        name: "Action 1.2"
     },], [{
         icon: "",
-        name: "Action 2.1",
-        onClick: jest.fn()
+        name: "Action 2.1"
     }]];
 
-    it("calls provided actions with provided item", () => {
+    it("calls onActionClick with clicked action", () => {
+        const onActionClick = jest.fn();
         const contextMenu = mount(<ContextMenu
             anchorEl={document.createElement("div")}
             onClose={jest.fn()}
-            {...{ actions, item }} />);
-
-        contextMenu.find(ListItem).at(0).simulate("click");
-        contextMenu.find(ListItem).at(1).simulate("click");
+            onActionClick={onActionClick}
+            actions={actions} />);
         contextMenu.find(ListItem).at(2).simulate("click");
-
-        expect(actions[0][0].onClick).toHaveBeenCalledWith(item);
-        expect(actions[0][1].onClick).toHaveBeenCalledWith(item);
-        expect(actions[1][0].onClick).toHaveBeenCalledWith(item);
+        expect(onActionClick).toHaveBeenCalledWith(actions[1][0]);
     });
 });
\ No newline at end of file
index c86c5177a319945cd45635a28bb650d1041cd249..7751be49d2c049049106d9492226f205fca457cf 100644 (file)
@@ -5,27 +5,26 @@ import * as React from "react";
 import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
 import { DefaultTransformOrigin } from "../popover/helpers";
 
-export interface ContextMenuAction<T> {
+export interface ContextMenuAction {
     name: string;
     icon: string;
-    onClick: (item: T) => void;
 }
 
-export type ContextMenuActionGroup<T> = Array<ContextMenuAction<T>>;
+export type ContextMenuActionGroup = ContextMenuAction[];
 
 export interface ContextMenuProps<T> {
     anchorEl?: HTMLElement;
-    item?: T;
+    actions: ContextMenuActionGroup[];
+    onActionClick: (action: ContextMenuAction) => void;
     onClose: () => void;
-    actions: Array<ContextMenuActionGroup<T>>;
 }
 
 export default class ContextMenu<T> extends React.PureComponent<ContextMenuProps<T>> {
     render() {
-        const { anchorEl, onClose, actions, item } = this.props;
+        const { anchorEl, actions, onClose, onActionClick } = this.props;
         return <Popover
             anchorEl={anchorEl}
-            open={Boolean(anchorEl)}
+            open={!!anchorEl}
             onClose={onClose}
             transformOrigin={DefaultTransformOrigin}
             anchorOrigin={DefaultTransformOrigin}>
@@ -36,7 +35,7 @@ export default class ContextMenu<T> extends React.PureComponent<ContextMenuProps
                             <ListItem
                                 button
                                 key={actionIndex}
-                                onClick={() => item && action.onClick(item)}>
+                                onClick={() => onActionClick(action)}>
                                 <ListItemIcon>
                                     <i className={action.icon} />
                                 </ListItemIcon>
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..1ce5641
--- /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").slice(0, -1)).toEqual(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);
index d3b147362651256d2c1cf546643ef2788de2c7f0..1ef7d98fbe5b77dfada2fc45fa261874f6868d4c 100644 (file)
@@ -2,15 +2,33 @@
 //
 // 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;
-};
\ No newline at end of file
+};
+
+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;
+};
index 4a34a6b729e31eac6740fb0fef8f89c5b593529f..439e6c27e906fa2b15264fc9c8b10fcada1f04cb 100644 (file)
@@ -4,10 +4,11 @@
 
 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 { TableHead, TableCell, Typography, TableBody, Button } from "@material-ui/core";
+import DataTableFilters from "../data-table-filters/data-table-filters";
 
 configure({ adapter: new Adapter() });
 
@@ -30,10 +31,16 @@ describe("<DataTable />", () => {
                 selected: false
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        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>> = [
             {
@@ -42,10 +49,16 @@ describe("<DataTable />", () => {
                 selected: true
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        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>> = [
             {
@@ -55,10 +68,16 @@ describe("<DataTable />", () => {
                 selected: true
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        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>> = [
             {
@@ -68,11 +87,17 @@ describe("<DataTable />", () => {
                 selected: true
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        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>> = [
             {
@@ -81,7 +106,13 @@ describe("<DataTable />", () => {
                 selected: true
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={[]}/>);
+        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");
     });
 
@@ -98,10 +129,57 @@ describe("<DataTable />", () => {
                 selected: true
             }
         ];
-        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        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
index 37e0fe15d52fa7d31272cb9c005034f86d1cfae3..e86113efdeac14a6e0f1928265b622875f582f19 100644 (file)
@@ -3,52 +3,34 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Table, TableBody, TableRow, TableCell, TableHead, StyleRulesCallback, Theme, WithStyles, withStyles, Typography } from '@material-ui/core';
-import { DataColumn } from './data-column';
+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;
+    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, columns, classes, onRowClick, onRowContextMenu } = this.props;
+        const { items, classes } = this.props;
         return <div className={classes.tableContainer}>
             {items.length > 0 ?
                 <Table>
                     <TableHead>
                         <TableRow>
-                            {columns
-                                .filter(column => column.selected)
-                                .map(({ name, renderHeader, key }, index) =>
-                                    <TableCell key={key || index}>
-                                        {renderHeader ? renderHeader() : name}
-                                    </TableCell>
-                                )}
+                            {this.mapVisibleColumns(this.renderHeadCell)}
                         </TableRow>
                     </TableHead>
                     <TableBody className={classes.tableBody}>
-                        {items
-                            .map((item, index) =>
-                                <TableRow
-                                    hover
-                                    key={index}
-                                    onClick={event => onRowClick && onRowClick(event, item)}
-                                    onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}>
-                                    {columns
-                                        .filter(column => column.selected)
-                                        .map((column, index) => (
-                                            <TableCell key={column.key || index}>
-                                                {column.render(item)}
-                                            </TableCell>
-                                        ))}
-                                </TableRow>
-                            )}
+                        {items.map(this.renderBodyRow)}
                     </TableBody>
                 </Table> : <Typography
                     className={classes.noItemsInfo}
@@ -58,6 +40,56 @@ class DataTable<T> extends React.Component<DataTableProps<T> & WithStyles<CssRul
                 </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";
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
diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx
new file mode 100644 (file)
index 0000000..ac20730
--- /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 { ReactElement } from 'react';
+import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles';
+import List from "@material-ui/core/List/List";
+import ListItem from "@material-ui/core/ListItem/ListItem";
+import ListItemText from "@material-ui/core/ListItemText/ListItemText";
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import Collapse from "@material-ui/core/Collapse/Collapse";
+
+import { Typography } from '@material-ui/core';
+
+export interface SidePanelItem {
+    id: string;
+    name: string;
+    icon: string;
+    active?: boolean;
+    open?: boolean;
+    margin?: boolean;
+    openAble?: boolean;
+}
+
+interface SidePanelProps {
+    toggleOpen: (id: string) => void;
+    toggleActive: (id: string) => void;
+    sidePanelItems: SidePanelItem[];
+}
+
+class SidePanel extends React.Component<SidePanelProps & WithStyles<CssRules>> {
+    render(): ReactElement<any> {
+        const { classes, toggleOpen, toggleActive, sidePanelItems, children } = this.props;
+        const { listItemText, leftSidePanelContainer, row, list, icon, projectIconMargin, active, activeArrow, inactiveArrow, arrowTransition, arrowRotate } = classes;
+        return (
+            <div className={leftSidePanelContainer}>
+                <List>
+                    {sidePanelItems.map(it => (
+                        <span key={it.name}>
+                            <ListItem button className={list} onClick={() => toggleActive(it.id)}>
+                                <span className={row}>
+                                    {it.openAble ? <i onClick={() => toggleOpen(it.id)} className={`${it.active ? activeArrow : inactiveArrow} 
+                                        ${it.open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} /> : null}
+                                    <ListItemIcon className={it.active ? active : ''}>
+                                        <i className={`${it.icon} ${icon} ${it.margin ? projectIconMargin : ''}`} />
+                                    </ListItemIcon>
+                                    <ListItemText className={listItemText} primary={<Typography className={it.active ? active : ''}>{it.name}</Typography>} />
+                                </span>
+                            </ListItem>
+                            {it.openAble ? (
+                                <Collapse in={it.open} timeout="auto" unmountOnExit>
+                                    {children}
+                                </Collapse>) : null}
+                        </span>
+                    ))}
+                </List>
+            </div>
+        );
+    }
+}
+
+type CssRules = 'active' | 'listItemText' | 'row' | 'leftSidePanelContainer' | 'list' | 'icon' | 'projectIconMargin' |
+    'activeArrow' | 'inactiveArrow' | 'arrowRotate' | 'arrowTransition';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    active: {
+        color: '#4285F6',
+    },
+    listItemText: {
+        padding: '0px',
+    },
+    row: {
+        display: 'flex',
+        alignItems: 'center',
+    },
+    activeArrow: {
+        color: '#4285F6',
+        position: 'absolute',
+    },
+    inactiveArrow: {
+        position: 'absolute',
+    },
+    arrowTransition: {
+        transition: 'all 0.1s ease',
+    },
+    arrowRotate: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(-90deg)',
+    },
+    leftSidePanelContainer: {
+        overflowY: 'auto',
+        minWidth: '240px',
+        whiteSpace: 'nowrap',
+        marginTop: '38px',
+        display: 'flex',
+        flexGrow: 1,
+    },
+    list: {
+        paddingBottom: '5px',
+        paddingTop: '5px',
+        paddingLeft: '14px',
+        minWidth: '240px',
+    },
+    icon: {
+        minWidth: '20px',
+    },
+    projectIconMargin: {
+        marginLeft: '17px',
+    }
+});
+
+export default withStyles(styles)(SidePanel);
\ No newline at end of file
index 0e69554a6225bb7181b006c71db0186b583de6e2..2c19a831ecd1154bfe7de3746787a6b3d6641eb3 100644 (file)
@@ -9,38 +9,6 @@ import { StyleRulesCallback, Theme, withStyles, WithStyles } from '@material-ui/
 import { ReactElement } from "react";
 import Collapse from "@material-ui/core/Collapse/Collapse";
 import CircularProgress from '@material-ui/core/CircularProgress';
-import { inherits } from 'util';
-
-type CssRules = 'list' | 'activeArrow' | 'inactiveArrow' | 'arrowRotate' | 'arrowTransition' | 'loader' | 'arrowVisibility';
-
-const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
-    list: {
-        paddingBottom: '3px',
-        paddingTop: '3px',
-    },
-    activeArrow: {
-        color: '#4285F6',
-        position: 'absolute',
-    },
-    inactiveArrow: {
-        position: 'absolute',
-    },
-    arrowTransition: {
-        transition: 'all 0.1s ease',
-    },
-    arrowRotate: {
-        transition: 'all 0.1s ease',
-        transform: 'rotate(-90deg)',
-    },
-    arrowVisibility: {
-        opacity: 0,
-    },
-    loader: {
-        position: 'absolute',
-        transform: 'translate(0px)',
-        top: '3px'
-    }
-});
 
 export enum TreeItemStatus {
     Initial,
@@ -61,27 +29,28 @@ export interface TreeItem<T> {
 interface TreeProps<T> {
     items?: Array<TreeItem<T>>;
     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
-    toggleItem: (itemId: string) => any;
+    toggleItemOpen: (id: string, status: TreeItemStatus) => void;
+    toggleItemActive: (id: string, status: TreeItemStatus) => void;
     level?: number;
 }
 
 class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
     renderArrow(status: TreeItemStatus, arrowClass: string, open: boolean, id: string) {
-        return <i
-            onClick={() => this.props.toggleItem(id)}
+        const { arrowTransition, arrowVisibility, arrowRotate } = this.props.classes;
+        return <i onClick={() => this.props.toggleItemOpen(id, status)}
             className={`
-                ${arrowClass}
-                ${status === TreeItemStatus.Pending ? this.props.classes.arrowVisibility : ''}
-                ${open ? `fas fa-caret-down ${this.props.classes.arrowTransition}` : `fas fa-caret-down ${this.props.classes.arrowRotate}`}`} />;
+                    ${arrowClass} 
+                    ${status === TreeItemStatus.Pending ? arrowVisibility : ''} 
+                    ${open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} />;
     }
     render(): ReactElement<any> {
         const level = this.props.level ? this.props.level : 0;
-        const { classes, render, toggleItem, items } = this.props;
+        const { classes, render, toggleItemOpen, items, toggleItemActive } = this.props;
         const { list, inactiveArrow, activeArrow, loader } = classes;
         return <List component="div" className={list}>
             {items && items.map((it: TreeItem<T>, idx: number) =>
                 <div key={`item/${level}/${idx}`}>
-                    <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }}>
+                    <ListItem button className={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)}
@@ -91,7 +60,8 @@ class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
                             <StyledTree
                                 items={it.items}
                                 render={render}
-                                toggleItem={toggleItem}
+                                toggleItemOpen={toggleItemOpen}
+                                toggleItemActive={toggleItemActive}
                                 level={level + 1} />
                         </Collapse>}
                 </div>)}
@@ -99,5 +69,36 @@ class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
     }
 }
 
+type CssRules = 'list' | 'activeArrow' | 'inactiveArrow' | 'arrowRotate' | 'arrowTransition' | 'loader' | 'arrowVisibility';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    list: {
+        paddingBottom: '3px',
+        paddingTop: '3px',
+    },
+    activeArrow: {
+        color: '#4285F6',
+        position: 'absolute',
+    },
+    inactiveArrow: {
+        position: 'absolute',
+    },
+    arrowTransition: {
+        transition: 'all 0.1s ease',
+    },
+    arrowRotate: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(-90deg)',
+    },
+    arrowVisibility: {
+        opacity: 0,
+    },
+    loader: {
+        position: 'absolute',
+        transform: 'translate(0px)',
+        top: '3px'
+    }
+});
+
 const StyledTree = withStyles(styles)(Tree);
 export default StyledTree;
index 0cd443b702094bca957349b30d3aa26fb1693552..bc9f903d288765b70470fba499b416902df07204 100644 (file)
@@ -30,7 +30,8 @@ const store = configureStore({
     },
     auth: {
         user: undefined
-    }
+    },
+    sidePanel: []
 }, history);
 
 store.dispatch(authActions.INIT());
index 80318ec7c6625d5774301687d7c62f897b84b9c0..6d1754d92cd827f3d191f6ff1510c50a1bd541af 100644 (file)
@@ -10,6 +10,7 @@ import { getCollectionList } from "../collection/collection-action";
 import { findTreeItem } from "../project/project-reducer";
 import { Project } from "../../models/project";
 import { Resource, ResourceKind } from "../../models/resource";
+import sidePanelActions from "../side-panel/side-panel-action";
 
 export const getResourceUrl = (resource: Resource): string => {
     switch (resource.kind) {
@@ -24,7 +25,9 @@ export const getResourceUrl = (resource: Resource): string => {
 export const setProjectItem = (projects: Array<TreeItem<Project>>, itemId: string, itemKind: ResourceKind) => (dispatch: Dispatch) => {
 
     const openProjectItem = (resource: Resource) => {
-        dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(resource.uuid));
+        dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(resource.uuid));
+        dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(resource.uuid));
+        dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(resource.uuid));
         dispatch(push(getResourceUrl({...resource, kind: itemKind})));
     };
     const treeItem = findTreeItem(projects, itemId);
@@ -45,3 +48,18 @@ export const setProjectItem = (projects: Array<TreeItem<Project>>, itemId: strin
 
     }
 };
+
+    // 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));
+    //             });
+    //     }
+    // }
index 728b1cc95e587112104d032f6cbc56698fe45426..3c264d3ef9fcbba5089a9294a65b3aec0d863c5b 100644 (file)
@@ -1,29 +1,33 @@
 // Copyright (C) The Arvados Authors. All rights reserved.
 //
 // SPDX-License-Identifier: AGPL-3.0
+import { default as unionize, ofType, UnionOf } from "unionize";
 
 import { Project } from "../../models/project";
-import { default as unionize, ofType, UnionOf } from "unionize";
 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: ofType<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[]> => {
-    dispatch(actions.PROJECTS_REQUEST());
-    return projectService.getProjectList(parentUuid).then(projects => {
-        dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid}));
-        return projects;
-    });
+    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>;
index 8082809b08465b474bcfd8cab41b15117d672021..65a856bddb8720109cf8d75f044d346c955c4de6 100644 (file)
@@ -39,26 +39,154 @@ describe('project-reducer', () => {
         const projects = [project, project];
         const state = projectsReducer(initialState, actions.PROJECTS_SUCCESS({ projects, parentItemId: undefined }));
         expect(state).toEqual([{
+            active: false,
+            open: false,
+            id: "test123",
+            items: [],
+            data: project,
+            status: 0
+        }, {
+            active: false,
+            open: false,
+            id: "test123",
+            items: [],
+            data: project,
+            status: 0
+        }
+        ]);
+    });
+
+    it('should remove activity on projects list', () => {
+        const initialState = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
+                open: true,
+                active: true,
+                status: 1
+            }],
+            currentItemId: "1"
+        };
+        const project = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
+                open: true,
                 active: false,
-                open: false,
-                id: "test123",
-                items: [],
-                data: project,
-                status: 0
-            }, {
+                status: 1
+            }],
+            currentItemId: "1"
+        };
+
+        const state = projectsReducer(initialState, actions.RESET_PROJECT_TREE_ACTIVITY(initialState[0].id));
+        expect(state).toEqual(project);
+    });
+
+    it('should toggle project tree item activity', () => {
+        const initialState = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
+                open: true,
                 active: false,
+                status: 1
+            }],
+            currentItemId: "1"
+        };
+        const project = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
+                open: true,
+                active: true,
+                status: 1
+            }],
+            currentItemId: "1"
+        };
+
+        const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState[0].id));
+        expect(state).toEqual(project);
+    });
+
+
+    it('should close project tree item ', () => {
+        const initialState = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
+                open: true,
+                active: false,
+                status: 1,
+                toggled: false,
+            }],
+            currentItemId: "1"
+        };
+        const project = {
+            items: [{
+                data: {
+                    name: 'test',
+                    href: 'href',
+                    createdAt: '2018-01-01',
+                    modifiedAt: '2018-01-01',
+                    ownerUuid: 'owner-test123',
+                    uuid: 'test123',
+                    kind: ResourceKind.PROJECT
+                },
+                id: "1",
                 open: false,
-                id: "test123",
-                items: [],
-                data: project,
-                status: 0
-            }
-        ]);
+                active: false,
+                status: 1,
+                toggled: true
+            }],
+            currentItemId: "1"
+        };
+
+        const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState[0].id));
+        expect(state).toEqual(project);
     });
 });
 
 describe("findTreeBranch", () => {
-
     const createTreeItem = (id: string, items?: Array<TreeItem<string>>): TreeItem<string> => ({
         id,
         items,
index a40d48d4bfd0424d2171f43d4cf580c94938c735..39d194ddbd96435dccea29116faa2739dd1653eb 100644 (file)
@@ -2,10 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import * as _ from "lodash";
+
 import { Project } from "../../models/project";
 import actions, { ProjectAction } from "./project-action";
 import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
-import * as _ from "lodash";
 
 export type ProjectState = {
     items: Array<TreeItem<Project>>,
@@ -114,20 +115,38 @@ const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" },
                 items: updateProjectTree(state.items, projects, parentItemId)
             };
         },
-        TOGGLE_PROJECT_TREE_ITEM: itemId => {
+        TOGGLE_PROJECT_TREE_ITEM_OPEN: itemId => {
             const items = _.cloneDeep(state.items);
-            resetTreeActivity(items);
             const item = findTreeItem(items, itemId);
             if (item) {
+                item.toggled = true;
                 item.open = !item.open;
+            }
+            return {
+                ...state,
+                items
+            };
+        },
+        TOGGLE_PROJECT_TREE_ITEM_ACTIVE: itemId => {
+            const items = _.cloneDeep(state.items);
+            resetTreeActivity(items);
+            const item = findTreeItem(items, itemId);
+            if (item) {
                 item.active = true;
-                item.toggled = true;
             }
             return {
                 items,
                 currentItemId: itemId
             };
         },
+        RESET_PROJECT_TREE_ACTIVITY: () => {
+            const items = _.cloneDeep(state.items);
+            resetTreeActivity(items);
+            return {
+                ...state,
+                items
+            };
+        },
         default: () => state
     });
 };
diff --git a/src/store/side-panel/side-panel-action.ts b/src/store/side-panel/side-panel-action.ts
new file mode 100644 (file)
index 0000000..32fa653
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+
+const actions = unionize({
+    TOGGLE_SIDE_PANEL_ITEM_OPEN: ofType<string>(),
+    TOGGLE_SIDE_PANEL_ITEM_ACTIVE: ofType<string>(),
+    RESET_SIDE_PANEL_ACTIVITY: ofType<string>(),
+}, {
+    tag: 'type',
+    value: 'payload'
+});
+
+export type SidePanelAction = UnionOf<typeof actions>;
+export default actions;
\ No newline at end of file
diff --git a/src/store/side-panel/side-panel-reducer.test.ts b/src/store/side-panel/side-panel-reducer.test.ts
new file mode 100644 (file)
index 0000000..942c16e
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import sidePanelReducer from "./side-panel-reducer";
+import actions from "./side-panel-action";
+
+describe('side-panel-reducer', () => {
+
+    it('should toggle activity on side-panel', () => {
+        const initialState = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: false,
+                active: false,
+            }
+        ];
+        const project = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: false,
+                active: true,
+            }
+        ];
+
+        const state = sidePanelReducer(initialState, actions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(initialState[0].id));
+        expect(state).toEqual(project);
+    });
+
+    it('should open side-panel item', () => {
+        const initialState = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: false,
+                active: false,
+            }
+        ];
+        const project = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: true,
+                active: false,
+            }
+        ];
+
+        const state = sidePanelReducer(initialState, actions.TOGGLE_SIDE_PANEL_ITEM_OPEN(initialState[0].id));
+        expect(state).toEqual(project);
+    });
+
+    it('should remove activity on side-panel item', () => {
+        const initialState = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: false,
+                active: true,
+            }
+        ];
+        const project = [
+            {
+                id: "1",
+                name: "Projects",
+                icon: "fas fa-th fa-fw",
+                open: false,
+                active: false,
+            }
+        ];
+
+        const state = sidePanelReducer(initialState, actions.RESET_SIDE_PANEL_ACTIVITY(initialState[0].id));
+        expect(state).toEqual(project);
+    });
+});
\ No newline at end of file
diff --git a/src/store/side-panel/side-panel-reducer.ts b/src/store/side-panel/side-panel-reducer.ts
new file mode 100644 (file)
index 0000000..9fc5df1
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as _ from "lodash";
+
+import actions, { SidePanelAction } from './side-panel-action';
+import { SidePanelItem } from '../../components/side-panel/side-panel';
+
+export type SidePanelState = SidePanelItem[];
+
+const sidePanelReducer = (state: SidePanelState = sidePanelData, action: SidePanelAction) => {
+    if (state.length === 0) {
+        return sidePanelData;
+    } else {
+        return actions.match(action, {
+            TOGGLE_SIDE_PANEL_ITEM_OPEN: itemId => state.map(it => itemId === it.id && it.open === false ? {...it, open: true} : {...it, open: false}),
+            TOGGLE_SIDE_PANEL_ITEM_ACTIVE: itemId => {
+                const sidePanel = _.cloneDeep(state);
+                resetSidePanelActivity(sidePanel);
+                sidePanel.map(it => {
+                    if (it.id === itemId) {
+                        it.active = true;
+                    }
+                });
+                return sidePanel;
+            },
+            RESET_SIDE_PANEL_ACTIVITY: () => {
+                const sidePanel = _.cloneDeep(state);
+                resetSidePanelActivity(sidePanel);
+                return sidePanel;
+            },
+            default: () => state
+        });
+    }
+};
+
+export const sidePanelData = [
+    {
+        id: "1",
+        name: "Projects",
+        icon: "fas fa-th fa-fw",
+        open: false,
+        active: false,
+        margin: true,
+        openAble: true
+    },
+    {
+        id: "2",
+        name: "Shared with me",
+        icon: "fas fa-users fa-fw",
+        active: false,
+    },
+    {
+        id: "3",
+        name: "Workflows",
+        icon: "fas fa-cogs fa-fw",
+        active: false,
+    },
+    {
+        id: "4",
+        name: "Recent open",
+        icon: "icon-time fa-fw",
+        active: false,
+    },
+    {
+        id: "5",
+        name: "Favorites",
+        icon: "fas fa-star fa-fw",
+        active: false,
+    },
+    {
+        id: "6",
+        name: "Trash",
+        icon: "fas fa-trash-alt fa-fw",
+        active: false,
+    }
+];
+
+function resetSidePanelActivity(sidePanel: SidePanelItem[]) {
+    for (const t of sidePanel) {
+        t.active = false;
+    }
+}
+
+export default sidePanelReducer;
index 6053e0378a28864e05f1f270e82a425c30a95061..40b24a049d7bcb05cf320e466307a773e848a691 100644 (file)
@@ -6,13 +6,15 @@ import { createStore, applyMiddleware, compose, Middleware, combineReducers } fr
 import { routerMiddleware, routerReducer, RouterState } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 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, { CollectionState } from "./collection/collection-reducer";
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
-    window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
+        window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
     compose;
 
 export interface RootState {
@@ -20,13 +22,15 @@ export interface RootState {
     projects: ProjectState;
     collections: CollectionState;
     router: RouterState;
+    sidePanel: SidePanelState;
 }
 
 const rootReducer = combineReducers({
     auth: authReducer,
     projects: projectsReducer,
     collections: collectionsReducer,
-    router: routerReducer
+    router: routerReducer,
+    sidePanel: sidePanelReducer
 });
 
 
diff --git a/src/views-components/data-explorer/data-explorer.tsx b/src/views-components/data-explorer/data-explorer.tsx
deleted file mode 100644 (file)
index d9e1f80..0000000
+++ /dev/null
@@ -1,230 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Grid, Paper, Toolbar, Typography } from '@material-ui/core';
-import IconButton from '@material-ui/core/IconButton';
-import MoreVertIcon from "@material-ui/icons/MoreVert";
-import { formatDate, formatFileSize } from '../../common/formatters';
-import { DataItem } from './data-item';
-import DataTable, { DataColumns } from "../../components/data-table/data-table";
-import ContextMenu from "../../components/context-menu/context-menu";
-import ColumnSelector from "../../components/column-selector/column-selector";
-import { mockAnchorFromMouseEvent } from "../../components/popover/helpers";
-import { DataColumn } from "../../components/data-table/data-column";
-import { ResourceKind } from "../../models/resource";
-
-export interface DataExplorerContextActions {
-    onAddToFavourite: (dataIitem: DataItem) => void;
-    onCopy: (dataIitem: DataItem) => void;
-    onDownload: (dataIitem: DataItem) => void;
-    onMoveTo: (dataIitem: DataItem) => void;
-    onRemove: (dataIitem: DataItem) => void;
-    onRename: (dataIitem: DataItem) => void;
-    onShare: (dataIitem: DataItem) => void;
-}
-interface DataExplorerProps {
-    items: DataItem[];
-    onItemClick: (item: DataItem) => void;
-    contextActions: DataExplorerContextActions;
-}
-
-interface DataExplorerState {
-    columns: DataColumns<DataItem>;
-    contextMenu: {
-        anchorEl?: HTMLElement;
-        item?: DataItem;
-    };
-}
-
-class DataExplorer extends React.Component<DataExplorerProps, DataExplorerState> {
-    state: DataExplorerState = {
-        contextMenu: {},
-        columns: [{
-            name: "Name",
-            selected: true,
-            render: item => this.renderName(item)
-        }, {
-            name: "Status",
-            selected: true,
-            render: item => renderStatus(item.status)
-        }, {
-            name: "Type",
-            selected: true,
-            render: item => renderType(item.kind)
-        }, {
-            name: "Owner",
-            selected: true,
-            render: item => renderOwner(item.owner)
-        }, {
-            name: "File size",
-            selected: true,
-            render: item => renderFileSize(item.fileSize)
-        }, {
-            name: "Last modified",
-            selected: true,
-            render: item => renderDate(item.lastModified)
-        }, {
-            name: "Actions",
-            selected: true,
-            configurable: false,
-            renderHeader: () => null,
-            render: item => this.renderActions(item)
-        }]
-    };
-
-    contextMenuActions = [[{
-        icon: "fas fa-users fa-fw",
-        name: "Share",
-        onClick: this.handleContextAction("onShare")
-    }, {
-        icon: "fas fa-sign-out-alt fa-fw",
-        name: "Move to",
-        onClick: this.handleContextAction("onMoveTo")
-    }, {
-        icon: "fas fa-star fa-fw",
-        name: "Add to favourite",
-        onClick: this.handleContextAction("onAddToFavourite")
-    }, {
-        icon: "fas fa-edit fa-fw",
-        name: "Rename",
-        onClick: this.handleContextAction("onRename")
-    }, {
-        icon: "fas fa-copy fa-fw",
-        name: "Make a copy",
-        onClick: this.handleContextAction("onCopy")
-    }, {
-        icon: "fas fa-download fa-fw",
-        name: "Download",
-        onClick: this.handleContextAction("onDownload")
-    }], [{
-        icon: "fas fa-trash-alt fa-fw",
-        name: "Remove",
-        onClick: this.handleContextAction("onRemove")
-    }
-    ]];
-
-    render() {
-        return <Paper>
-            <ContextMenu
-                {...this.state.contextMenu}
-                actions={this.contextMenuActions}
-                onClose={this.closeContextMenu} />
-            <Toolbar>
-                <Grid container justify="flex-end">
-                    <ColumnSelector
-                        columns={this.state.columns}
-                        onColumnToggle={this.toggleColumn} />
-                </Grid>
-            </Toolbar>
-            <DataTable
-                columns={this.state.columns}
-                items={this.props.items}
-                onRowContextMenu={this.openItemMenuOnRowClick} />
-            <Toolbar />
-        </Paper>;
-    }
-
-    toggleColumn = (column: DataColumn<DataItem>) => {
-        const index = this.state.columns.indexOf(column);
-        const columns = this.state.columns.slice(0);
-        columns.splice(index, 1, { ...column, selected: !column.selected });
-        this.setState({ columns });
-    }
-
-    renderName = (item: DataItem) =>
-        <Grid
-            container
-            alignItems="center"
-            wrap="nowrap"
-            spacing={16}
-            onClick={() => this.props.onItemClick(item)}>
-            <Grid item>
-                {renderIcon(item)}
-            </Grid>
-            <Grid item>
-                <Typography color="primary">
-                    {item.name}
-                </Typography>
-            </Grid>
-        </Grid>
-
-    renderActions = (item: DataItem) =>
-        <Grid container justify="flex-end">
-            <IconButton onClick={event => this.openItemMenuOnActionsClick(event, item)}>
-                <MoreVertIcon />
-            </IconButton>
-        </Grid>
-
-    openItemMenuOnRowClick = (event: React.MouseEvent<HTMLElement>, item: DataItem) => {
-        event.preventDefault();
-        this.setState({
-            contextMenu: {
-                anchorEl: mockAnchorFromMouseEvent(event),
-                item
-            }
-        });
-    }
-
-    openItemMenuOnActionsClick = (event: React.MouseEvent<HTMLElement>, item: DataItem) => {
-        this.setState({
-            contextMenu: {
-                anchorEl: event.currentTarget,
-                item
-            }
-        });
-    }
-
-    closeContextMenu = () => {
-        this.setState({ contextMenu: {} });
-    }
-
-    handleContextAction(action: keyof DataExplorerContextActions) {
-        return (item: DataItem) => {
-            this.closeContextMenu();
-            this.props.contextActions[action](item);
-        };
-    }
-
-}
-
-const renderIcon = (dataItem: DataItem) => {
-    switch (dataItem.kind) {
-        case ResourceKind.LEVEL_UP:
-            return <i className="icon-level-up" style={{fontSize: "1rem"}}/>;
-        case ResourceKind.PROJECT:
-            return <i className="fas fa-folder fa-lg"/>;
-        case ResourceKind.COLLECTION:
-            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 = (status?: string) =>
-    <Typography noWrap align="center">
-        {status || "-"}
-    </Typography>;
-
-export default DataExplorer;
similarity index 93%
rename from src/views-components/data-explorer/data-item.ts
rename to src/views-components/project-explorer/project-explorer-item.ts
index cde2bd99f1d420b7d23da78e512fdb9ca4878e84..4fa3d3d67ceccfabf20462450a8367a1e9199b2b 100644 (file)
@@ -4,7 +4,7 @@
 
 import { getResourceKind, Resource, ResourceKind } from "../../models/resource";
 
-export interface DataItem {
+export interface ProjectExplorerItem {
     uuid: string;
     name: string;
     kind: ResourceKind;
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..1018ef5
--- /dev/null
@@ -0,0 +1,229 @@
+// 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';
+import { ResourceKind } from "../../models/resource";
+
+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[];
+    onRowClick: (item: ProjectExplorerItem) => void;
+}
+
+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.kind)
+        }, {
+            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={this.props.onRowClick}
+            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.kind) {
+        case ResourceKind.LEVEL_UP:
+            return <i className="icon-level-up" style={{fontSize: "1rem"}}/>;
+        case ResourceKind.PROJECT:
+            return <i className="fas fa-folder fa-lg"/>;
+        case ResourceKind.COLLECTION:
+            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;
index d53121304c817a83b113406b5451f8c433ca37b7..1ba3abb8bb39ddb2c40de1098b769b35bd7ad105 100644 (file)
@@ -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);
     });
index e4179987d6881090064d56397d4467b724833fbe..f51b65e054df7f8ab2d69a263e658f9a1fe2a7d8 100644 (file)
@@ -12,6 +12,36 @@ import Typography from '@material-ui/core/Typography';
 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, status: TreeItemStatus) => void;
+}
+
+class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
+    render(): ReactElement<any> {
+        const { classes, projects, toggleOpen, toggleActive } = this.props;
+        const { active, listItemText, row, treeContainer } = classes;
+        return (
+            <div className={treeContainer}>
+                <Tree items={projects}
+                    toggleItemOpen={toggleOpen}
+                    toggleItemActive={toggleActive}
+                    render={(project: TreeItem<Project>) =>
+                        <span className={row}>
+                            <ListItemIcon className={project.active ? active : ''}>
+                                <i className="fas fa-folder" />
+                            </ListItemIcon>
+                            <ListItemText className={listItemText} primary={
+                                <Typography className={project.active ? active : ''}>{project.data.name}</Typography>
+                            } />
+                        </span>
+                    } />
+            </div>
+        );
+    }
+}
+
 type CssRules = 'active' | 'listItemText' | 'row' | 'treeContainer';
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
@@ -27,42 +57,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         marginLeft: '20px',
     },
     treeContainer: {
-        marginTop: '37px',
-        overflowX: 'visible',
-        overflowY: 'auto',
         minWidth: '240px',
         whiteSpace: 'nowrap',
+        marginLeft: '13px',
     }
 });
 
-export interface ProjectTreeProps {
-    projects: Array<TreeItem<Project>>;
-    toggleProjectTreeItem: (itemId: string) => void;
-}
-
-class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
-    render(): ReactElement<any> {
-        const {classes, projects} = this.props;
-        const {active, listItemText, row, treeContainer} = classes;
-        return (
-            <div className={treeContainer}>
-                <Tree items={projects}
-                    toggleItem={this.props.toggleProjectTreeItem}
-                    render={(project: TreeItem<Project>, level: number) =>
-                        <span className={row}>
-                            <ListItemIcon className={project.active ? active : ''}>
-                                {level === 0 ? <i className="fas fa-th"/> : <i className="fas fa-folder"/>}
-                            </ListItemIcon>
-                            <ListItemText className={listItemText} primary={
-                                <Typography className={project.active ? active : ''}>
-                                    {project.data.name}
-                                </Typography>
-                            }/>
-                        </span>
-                    }/>
-            </div>
-        );
-    }
-}
-
 export default withStyles(styles)(ProjectTree);
diff --git a/src/views/data-explorer/data-explorer.tsx b/src/views/data-explorer/data-explorer.tsx
deleted file mode 100644 (file)
index 8d225cd..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { RouteComponentProps } from 'react-router';
-import { Project } from '../../models/project';
-import { ProjectState } from '../../store/project/project-reducer';
-import { RootState } from '../../store/store';
-import { connect, DispatchProp } from 'react-redux';
-import { DataColumns } from "../../components/data-table/data-table";
-import DataExplorer, { DataExplorerContextActions } from "../../views-components/data-explorer/data-explorer";
-import { projectExplorerItems } from "./data-explorer-selectors";
-import { DataItem } from "../../views-components/data-explorer/data-item";
-import { CollectionState } from "../../store/collection/collection-reducer";
-import { setProjectItem } from "../../store/navigation/navigation-action";
-
-interface DataExplorerViewDataProps {
-    projects: ProjectState;
-    collections: CollectionState;
-}
-
-type DataExplorerViewProps = DataExplorerViewDataProps & RouteComponentProps<{ uuid: string }> & DispatchProp;
-type DataExplorerViewState = DataColumns<Project>;
-
-class DataExplorerView extends React.Component<DataExplorerViewProps, DataExplorerViewState> {
-    render() {
-        console.log('VIEW!');
-        const items = projectExplorerItems(
-            this.props.projects.items,
-            this.props.projects.currentItemId,
-            this.props.collections
-        );
-        return (
-            <DataExplorer
-                items={items}
-                onItemClick={this.goToItem}
-                contextActions={this.contextActions}
-            />
-        );
-    }
-
-    contextActions: DataExplorerContextActions = {
-        onAddToFavourite: console.log,
-        onCopy: console.log,
-        onDownload: console.log,
-        onMoveTo: console.log,
-        onRemove: console.log,
-        onRename: console.log,
-        onShare: console.log
-    };
-
-    goToItem = (item: DataItem) => {
-        this.props.dispatch<any>(setProjectItem(this.props.projects.items, item.uuid, item.kind));
-    }
-}
-
-export default connect(
-    (state: RootState) => ({
-        projects: state.projects,
-        collections: state.collections
-    })
-)(DataExplorerView);
similarity index 84%
rename from src/views/data-explorer/data-explorer-selectors.ts
rename to src/views/project-panel/project-panel-selectors.ts
index ceb87d22cd637ea1a565ea853b8cf3d6273b1e6f..c798ec3db2976396685bddd5bb60c63ba7312258 100644 (file)
@@ -4,14 +4,14 @@
 
 import { TreeItem } from "../../components/tree/tree";
 import { Project } from "../../models/project";
-import { DataItem } from "../../views-components/data-explorer/data-item";
 import { findTreeItem } from "../../store/project/project-reducer";
 import { ResourceKind } from "../../models/resource";
 import { Collection } from "../../models/collection";
 import { getResourceUrl } from "../../store/navigation/navigation-action";
+import { ProjectExplorerItem } from "../../views-components/project-explorer/project-explorer-item";
 
-export const projectExplorerItems = (projects: Array<TreeItem<Project>>, treeItemId: string, collections: Array<Collection>): DataItem[] => {
-    const dataItems: DataItem[] = [];
+export const projectExplorerItems = (projects: Array<TreeItem<Project>>, treeItemId: string, collections: Array<Collection>): ProjectExplorerItem[] => {
+    const dataItems: ProjectExplorerItem[] = [];
 
     const treeItem = findTreeItem(projects, treeItemId);
     if (treeItem) {
@@ -33,7 +33,7 @@ export const projectExplorerItems = (projects: Array<TreeItem<Project>>, treeIte
                     owner: p.data.ownerUuid,
                     uuid: p.data.uuid,
                     lastModified: p.data.modifiedAt
-                } as DataItem;
+                } as ProjectExplorerItem;
 
                 dataItems.push(item);
             });
@@ -48,7 +48,7 @@ export const projectExplorerItems = (projects: Array<TreeItem<Project>>, treeIte
             owner: c.ownerUuid,
             uuid: c.uuid,
             lastModified: c.modifiedAt
-        } as DataItem;
+        } as ProjectExplorerItem;
 
         dataItems.push(item);
     });
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..68cbc8e
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { RouteComponentProps } from 'react-router';
+import { ProjectState } from '../../store/project/project-reducer';
+import { RootState } from '../../store/store';
+import { connect, DispatchProp } from 'react-redux';
+import { CollectionState } from "../../store/collection/collection-reducer";
+import { setProjectItem } from "../../store/navigation/navigation-action";
+import ProjectExplorer, { ProjectExplorerContextActions } from "../../views-components/project-explorer/project-explorer";
+import { projectExplorerItems } from "./project-panel-selectors";
+import { ProjectExplorerItem } from "../../views-components/project-explorer/project-explorer-item";
+
+interface ProjectPanelDataProps {
+    projects: ProjectState;
+    collections: CollectionState;
+}
+
+type ProjectPanelProps = ProjectPanelDataProps & RouteComponentProps<{ name: string }> & DispatchProp;
+
+class ProjectPanel extends React.Component<ProjectPanelProps> {
+    render() {
+        const items = projectExplorerItems(
+            this.props.projects.items,
+            this.props.projects.currentItemId,
+            this.props.collections
+        );
+        return (
+            <ProjectExplorer
+                items={items}
+                onRowClick={this.goToItem}
+            />
+        );
+    }
+
+    goToItem = (item: ProjectExplorerItem) => {
+        this.props.dispatch<any>(setProjectItem(this.props.projects.items, item.uuid, item.kind));
+    }
+}
+
+export default connect(
+    (state: RootState) => ({
+        projects: state.projects,
+        collections: state.collections
+    })
+)(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 aed281567cef07936e50019a3ee6a57eed72c754..b6a8457a6fd3e482f23b04d6e0deae6fb13c5d32 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 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";
@@ -21,9 +20,11 @@ import ProjectTree from '../../views-components/project-tree/project-tree';
 import { TreeItem } from "../../components/tree/tree";
 import { Project } from "../../models/project";
 import { getTreePath } from '../../store/project/project-reducer';
-import DataExplorer from '../data-explorer/data-explorer';
-import { setProjectItem } from "../../store/navigation/navigation-action";
+import ProjectPanel from '../project-panel/project-panel';
+import sidePanelActions from '../../store/side-panel/side-panel-action';
+import SidePanel, { SidePanelItem } from '../../components/side-panel/side-panel';
 import { ResourceKind } from "../../models/resource";
+import { setProjectItem } from "../../store/navigation/navigation-action";
 
 const drawerWidth = 240;
 const appBarHeight = 102;
@@ -49,6 +50,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     drawerPaper: {
         position: 'relative',
         width: drawerWidth,
+        display: 'flex',
+        flexDirection: 'column',
     },
     contentWrapper: {
         backgroundColor: theme.palette.background.default,
@@ -69,6 +72,7 @@ interface WorkbenchDataProps {
     projects: Array<TreeItem<Project>>;
     currentProjectId: string;
     user?: User;
+    sidePanelItems: SidePanelItem[];
 }
 
 interface WorkbenchActionProps {
@@ -136,6 +140,15 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         onMenuItemClick: (menuItem: NavMenuItem) => menuItem.action()
     };
 
+    toggleSidePanelOpen = (itemId: string) => {
+        this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(itemId));
+    }
+
+    toggleSidePanelActive = (itemId: string) => {
+        this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(itemId));
+        // this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
+    }
+
     render() {
         const branch = getTreePath(this.props.projects, this.props.currentProjectId);
         const breadcrumbs = branch.map(item => ({
@@ -156,23 +169,34 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                         {...this.mainAppBarActions}
                     />
                 </div>
-                {user && <Drawer
-                    variant="permanent"
-                    classes={{
-                        paper: classes.drawerPaper,
-                    }}>
-                    <div className={classes.toolbar} />
-                    <ProjectTree
-                        projects={this.props.projects}
-                        toggleProjectTreeItem={itemId =>
-                            this.props.dispatch<any>(
-                                setProjectItem(this.props.projects, itemId, ResourceKind.PROJECT)
-                            )}/>
-                </Drawer>}
+                {user &&
+                    <Drawer
+                        variant="permanent"
+                        classes={{
+                            paper: classes.drawerPaper,
+                        }}>
+                        <div className={classes.toolbar} />
+                        <SidePanel
+                            toggleOpen={this.toggleSidePanelOpen}
+                            toggleActive={this.toggleSidePanelActive}
+                            sidePanelItems={this.props.sidePanelItems}>
+                            <ProjectTree
+                                projects={this.props.projects}
+                                toggleOpen={itemId =>
+                                    this.props.dispatch<any>(
+                                        setProjectItem(this.props.projects, itemId, ResourceKind.PROJECT)
+                                    )}
+                                toggleActive={itemId =>
+                                    this.props.dispatch<any>(
+                                        setProjectItem(this.props.projects, itemId, ResourceKind.PROJECT)
+                                    )}
+                            />
+                        </SidePanel>
+                    </Drawer>}
                 <main className={classes.contentWrapper}>
                     <div className={classes.content}>
                         <Switch>
-                            <Route path="/projects/:uuid" component={DataExplorer} />
+                            <Route path="/project/:name" component={ProjectPanel} />
                         </Switch>
                     </div>
                 </main>
@@ -180,12 +204,21 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         );
     }
 }
+/*
+                    <ProjectTree
+                        projects={this.props.projects}
+                        toggleProjectTreeItem={itemId =>
+                            this.props.dispatch<any>(
+                                setProjectItem(this.props.projects, itemId, ResourceKind.PROJECT)
+                            )}/>
+*/
 
 export default connect<WorkbenchDataProps>(
     (state: RootState) => ({
         projects: state.projects.items,
         currentProjectId: state.projects.currentItemId,
-        user: state.auth.user
+        user: state.auth.user,
+        sidePanelItems: state.sidePanel
     })
 )(
     withStyles(styles)(Workbench)
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"