Merge branch '13601-basic-data-exploring-component'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 18 Jun 2018 10:31:41 +0000 (12:31 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 18 Jun 2018 10:31:41 +0000 (12:31 +0200)
refs #13601

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

18 files changed:
src/common/formatters.ts [new file with mode: 0644]
src/components/data-explorer/data-explorer.tsx [new file with mode: 0644]
src/components/data-explorer/data-item.ts [new file with mode: 0644]
src/components/data-explorer/index.ts [new file with mode: 0644]
src/components/data-table/column-selector/column-selector.test.tsx [new file with mode: 0644]
src/components/data-table/column-selector/column-selector.tsx [new file with mode: 0644]
src/components/data-table/data-column.ts [new file with mode: 0644]
src/components/data-table/data-table.test.tsx [new file with mode: 0644]
src/components/data-table/data-table.tsx [new file with mode: 0644]
src/components/data-table/index.ts [new file with mode: 0644]
src/components/popover/popover.test.tsx [new file with mode: 0644]
src/components/popover/popover.tsx [new file with mode: 0644]
src/models/project.ts
src/services/project-service/project-service.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/views/data-explorer/data-explorer.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

diff --git a/src/common/formatters.ts b/src/common/formatters.ts
new file mode 100644 (file)
index 0000000..1d9a520
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const formatDate = (isoDate: string) => {
+    const date = new Date(isoDate);
+    return date.toLocaleString();
+};
+
+export const formatFileSize = (size?: number) => {
+    if (typeof size === "number") {
+        for (const { base, unit } of fileSizes) {
+            if (size >= base) {
+                return `${(size / base).toFixed()} ${unit}`;
+            }
+        }
+    }
+    return "";
+};
+
+const fileSizes = [
+    {
+        base: 1000000000000,
+        unit: "TB"
+    },
+    {
+        base: 1000000000,
+        unit: "GB"
+    },
+    {
+        base: 1000000,
+        unit: "MB"
+    },
+    {
+        base: 1000,
+        unit: "KB"
+    },
+    {
+        base: 1,
+        unit: "B"
+    }
+];
\ No newline at end of file
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..9aeb28a
--- /dev/null
@@ -0,0 +1,191 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { DataTable, DataTableProps, DataColumn, ColumnSelector } from "../../components/data-table";
+import { Typography, Grid, ListItem, Divider, List, ListItemIcon, ListItemText, Paper, Toolbar } from '@material-ui/core';
+import IconButton, { IconButtonProps } from '@material-ui/core/IconButton';
+import MoreVertIcon from "@material-ui/icons/MoreVert";
+import Popover from '../popover/popover';
+import { formatFileSize, formatDate } from '../../common/formatters';
+import { DataItem } from './data-item';
+
+interface DataExplorerProps {
+    items: DataItem[];
+    onItemClick: (item: DataItem) => void;
+}
+
+type DataExplorerState = Pick<DataTableProps<DataItem>, "columns">;
+
+class DataExplorer extends React.Component<DataExplorerProps, DataExplorerState> {
+    state: DataExplorerState = {
+        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.type)
+            },
+            {
+                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: renderItemActions
+            }
+        ]
+    };
+
+    render() {
+        return <Paper>
+            <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} />
+            <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>
+
+}
+
+const renderIcon = (dataItem: DataItem) => {
+    switch (dataItem.type) {
+        case "arvados#group":
+            return <i className="fas fa-folder fa-lg" />;
+        case "arvados#groupList":
+            return <i className="fas fa-th fa-lg" />;
+        default:
+            return <i />;
+    }
+};
+
+const renderDate = (date: string) =>
+    <Typography noWrap>
+        {formatDate(date)}
+    </Typography>;
+
+const renderFileSize = (fileSize?: number) =>
+    <Typography noWrap>
+        {formatFileSize(fileSize)}
+    </Typography>;
+
+const renderOwner = (owner: string) =>
+    <Typography noWrap color="primary">
+        {owner}
+    </Typography>;
+
+const renderType = (type: string) =>
+    <Typography noWrap>
+        {type}
+    </Typography>;
+
+const renderStatus = (status?: string) =>
+    <Typography noWrap align="center">
+        {status || "-"}
+    </Typography>;
+
+const renderItemActions = () =>
+    <Grid container justify="flex-end">
+        <Popover triggerComponent={ItemActionsTrigger}>
+            <List dense>
+                {[{
+                    icon: "fas fa-users",
+                    label: "Share"
+                },
+                {
+                    icon: "fas fa-sign-out-alt",
+                    label: "Move to"
+                },
+                {
+                    icon: "fas fa-star",
+                    label: "Add to favourite"
+                },
+                {
+                    icon: "fas fa-edit",
+                    label: "Rename"
+                },
+                {
+                    icon: "fas fa-copy",
+                    label: "Make a copy"
+                },
+                {
+                    icon: "fas fa-download",
+                    label: "Download"
+                }].map(renderAction)}
+                < Divider />
+                {renderAction({ icon: "fas fa-trash-alt", label: "Remove" })}
+            </List>
+        </Popover>
+    </Grid>;
+
+const renderAction = (action: { label: string, icon: string }, index?: number) =>
+    <ListItem button key={index}>
+        <ListItemIcon>
+            <i className={action.icon} />
+        </ListItemIcon>
+        <ListItemText>
+            {action.label}
+        </ListItemText>
+    </ListItem>;
+
+const ItemActionsTrigger: React.SFC<IconButtonProps> = (props) =>
+    <IconButton {...props}>
+        <MoreVertIcon />
+    </IconButton>;
+
+export default DataExplorer;
diff --git a/src/components/data-explorer/data-item.ts b/src/components/data-explorer/data-item.ts
new file mode 100644 (file)
index 0000000..3a80992
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface DataItem {
+    name: string;
+    type: string;
+    owner: string;
+    lastModified: string;
+    fileSize?: number;
+    status?: string;
+}
\ No newline at end of file
diff --git a/src/components/data-explorer/index.ts b/src/components/data-explorer/index.ts
new file mode 100644 (file)
index 0000000..bde402d
--- /dev/null
@@ -0,0 +1,6 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export { default as DataExplorer } from "./data-explorer";
+export * from "./data-item";
\ No newline at end of file
diff --git a/src/components/data-table/column-selector/column-selector.test.tsx b/src/components/data-table/column-selector/column-selector.test.tsx
new file mode 100644 (file)
index 0000000..26c16a1
--- /dev/null
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure } from "enzyme";
+import * as Adapter from "enzyme-adapter-react-16";
+import ColumnSelector, { ColumnSelectorProps, ColumnSelectorTrigger } from "./column-selector";
+import { DataColumn } from "../data-column";
+import { ListItem, Checkbox } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<ColumnSelector />", () => {
+    it("shows only configurable columns", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: true,
+                configurable: true,
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true,
+                configurable: false
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(ListItem)).toHaveLength(2);
+    });
+
+    it("renders checked checkboxes next to selected columns", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: false
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        expect(columnsConfigurator.find(Checkbox).at(0).prop("checked")).toBe(true);
+        expect(columnsConfigurator.find(Checkbox).at(1).prop("checked")).toBe(false);
+        expect(columnsConfigurator.find(Checkbox).at(2).prop("checked")).toBe(true);
+    });
+
+    it("calls onColumnToggle with clicked column", () => {
+        const columns: Array<DataColumn<void>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const onColumnToggle = jest.fn();
+        const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={onColumnToggle} />);
+        columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+        columnsConfigurator.find(ListItem).simulate("click");
+        expect(onColumnToggle).toHaveBeenCalledWith(columns[0]);
+    });
+});
\ No newline at end of file
diff --git a/src/components/data-table/column-selector/column-selector.tsx b/src/components/data-table/column-selector/column-selector.tsx
new file mode 100644 (file)
index 0000000..87d3e8d
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithStyles, StyleRulesCallback, Theme, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem } from '@material-ui/core';
+import MenuIcon from "@material-ui/icons/Menu";
+import { DataColumn, isColumnConfigurable } from '../data-column';
+import Popover from "../../popover/popover";
+import { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface ColumnSelectorProps {
+    columns: Array<DataColumn<any>>;
+    onColumnToggle: (column: DataColumn<any>) => void;
+}
+
+const ColumnSelector: React.SFC<ColumnSelectorProps & WithStyles<CssRules>> = ({ columns, onColumnToggle, classes }) =>
+    <Popover triggerComponent={ColumnSelectorTrigger}>
+        <Paper>
+            <List dense>
+                {columns
+                    .filter(isColumnConfigurable)
+                    .map((column, index) => (
+                        <ListItem
+                            button
+                            key={index}
+                            onClick={() => onColumnToggle(column)}>
+                            <Checkbox
+                                disableRipple
+                                color="primary"
+                                checked={column.selected}
+                                className={classes.checkbox} />
+                            <ListItemText>
+                                {column.name}
+                            </ListItemText>
+                        </ListItem>
+                    ))}
+            </List>
+        </Paper>
+    </Popover>;
+
+export const ColumnSelectorTrigger: React.SFC<IconButtonProps> = (props) =>
+    <IconButton {...props}>
+        <MenuIcon />
+    </IconButton>;
+
+type CssRules = "checkbox";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    checkbox: {
+        width: 24,
+        height: 24
+    }
+});
+
+export default withStyles(styles)(ColumnSelector);
diff --git a/src/components/data-table/data-column.ts b/src/components/data-table/data-column.ts
new file mode 100644 (file)
index 0000000..d3b1473
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface DataColumn<T> {
+    name: string;
+    selected: boolean;
+    configurable?: boolean;
+    key?: React.Key;
+    render: (item: T) => React.ReactElement<void>;
+    renderHeader?: () => React.ReactElement<void> | null;
+}
+
+export const isColumnConfigurable = <T>(column: DataColumn<T>) => {
+    return column.configurable === undefined || column.configurable;
+};
\ No newline at end of file
diff --git a/src/components/data-table/data-table.test.tsx b/src/components/data-table/data-table.test.tsx
new file mode 100644 (file)
index 0000000..4a34a6b
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure } from "enzyme";
+import * as Adapter from "enzyme-adapter-react-16";
+import DataTable from "./data-table";
+import { DataColumn } from "./data-column";
+import { TableHead, TableCell, Typography, TableBody, Button } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTable />", () => {
+    it("shows only selected columns", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: () => <span />,
+                selected: true
+            },
+            {
+                name: "Column 3",
+                render: () => <span />,
+                selected: false
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
+    });
+    
+    it("renders column name", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
+    });
+    
+    it("uses renderHeader instead of name prop", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                renderHeader: () => <span>Column Header</span>,
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
+    });
+    
+    it("passes column key prop to corresponding cells", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                key: "column-1-key",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
+        expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
+    });
+    
+    it("shows information that items array is empty", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: () => <span />,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={[]}/>);
+        expect(dataTable.find(Typography).text()).toBe("No items");
+    });
+
+    it("renders items", () => {
+        const columns: Array<DataColumn<string>> = [
+            {
+                name: "Column 1",
+                render: (item) => <Typography>{item}</Typography>,
+                selected: true
+            },
+            {
+                name: "Column 2",
+                render: (item) => <Button>{item}</Button>,
+                selected: true
+            }
+        ];
+        const dataTable = mount(<DataTable columns={columns} items={["item 1"]}/>);
+        expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
+        expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
+    });
+
+
+});
\ No newline at end of file
diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx
new file mode 100644 (file)
index 0000000..e7ce03a
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Table, TableBody, TableRow, TableCell, TableHead, StyleRulesCallback, Theme, WithStyles, withStyles, Typography } from '@material-ui/core';
+import { DataColumn } from './data-column';
+
+export interface DataTableProps<T> {
+    items: T[];
+    columns: Array<DataColumn<T>>;
+    onItemClick?: (item: T) => void;
+}
+
+class DataTable<T> extends React.Component<DataTableProps<T> & WithStyles<CssRules>> {
+    render() {
+        const { items, columns, classes, onItemClick } = 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>
+                                )}
+                        </TableRow>
+                    </TableHead>
+                    <TableBody className={classes.tableBody}>
+                        {items
+                            .map((item, index) =>
+                                <TableRow
+                                    hover
+                                    key={index}
+                                    onClick={() => onItemClick && onItemClick(item)}>
+                                    {columns
+                                        .filter(column => column.selected)
+                                        .map((column, index) => (
+                                            <TableCell key={column.key || index}>
+                                                {column.render(item)}
+                                            </TableCell>
+                                        ))}
+                                </TableRow>
+                            )}
+                    </TableBody>
+                </Table> : <Typography 
+                    className={classes.noItemsInfo}
+                    variant="body2"
+                    gutterBottom>
+                    No items
+                </Typography>}
+        </div>;
+    }
+}
+
+type CssRules = "tableBody" | "tableContainer" | "noItemsInfo";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    tableContainer: {
+        overflowX: 'auto'
+    },
+    tableBody: {
+        background: theme.palette.background.paper
+    },
+    noItemsInfo: {
+        textAlign: "center",
+        padding: theme.spacing.unit
+    }
+});
+
+export default withStyles(styles)(DataTable);
diff --git a/src/components/data-table/index.ts b/src/components/data-table/index.ts
new file mode 100644 (file)
index 0000000..f35754b
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export * from "./data-column";
+export * from "./column-selector/column-selector";
+export { default as ColumnSelector } from "./column-selector/column-selector";
+export * from "./data-table";
+export { default as DataTable } from "./data-table";
\ No newline at end of file
diff --git a/src/components/popover/popover.test.tsx b/src/components/popover/popover.test.tsx
new file mode 100644 (file)
index 0000000..fa24c0c
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure } from "enzyme";
+import * as Adapter from "enzyme-adapter-react-16";
+
+import Popover, { DefaultTrigger } from "./popover";
+import Button, { ButtonProps } from "@material-ui/core/Button";
+
+configure({ adapter: new Adapter() });
+
+describe("<Popover />", () => {
+    it("opens on default trigger click", () => {
+        const popover = mount(<Popover />);
+        popover.find(DefaultTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+
+    it("renders custom trigger", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        expect(popover.find(Button).text()).toBe("Open popover");
+    });
+
+    it("opens on custom trigger click", () => {
+        const popover = mount(<Popover triggerComponent={CustomTrigger} />);
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+
+    it("renders children when opened", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        expect(popover.find(CustomTrigger)).toHaveLength(1);
+    });
+    
+    it("does not close if closeOnContentClick is not set", () => {
+        const popover = mount(
+            <Popover>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeDefined();
+    });
+    it("closes on content click if closeOnContentClick is set", () => {
+        const popover = mount(
+            <Popover closeOnContentClick>
+                <CustomTrigger />
+            </Popover>
+        );
+        popover.find(DefaultTrigger).simulate("click");
+        popover.find(CustomTrigger).simulate("click");
+        expect(popover.state().anchorEl).toBeUndefined();
+    });
+
+});
+
+const CustomTrigger: React.SFC<ButtonProps> = (props) => (
+    <Button {...props}>
+        Open popover
+    </Button>
+);
\ No newline at end of file
diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx
new file mode 100644 (file)
index 0000000..c8d4033
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Popover as MaterialPopover } from '@material-ui/core';
+
+import { PopoverOrigin } from '@material-ui/core/Popover';
+import IconButton, { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface PopoverProps {
+    triggerComponent?: React.ComponentType<{ onClick: (event: React.MouseEvent<any>) => void }>;
+    closeOnContentClick?: boolean;
+}
+
+
+class Popover extends React.Component<PopoverProps> {
+
+    state = {
+        anchorEl: undefined
+    };
+
+    transformOrigin: PopoverOrigin = {
+        vertical: "top",
+        horizontal: "right",
+    };
+
+    render() {
+        const Trigger = this.props.triggerComponent || DefaultTrigger;
+        return (
+            <>
+                <Trigger onClick={this.handleTriggerClick} />
+                <MaterialPopover
+                    anchorEl={this.state.anchorEl}
+                    open={Boolean(this.state.anchorEl)}
+                    onClose={this.handleClose}
+                    onClick={this.handleSelfClick}
+                    transformOrigin={this.transformOrigin}
+                    anchorOrigin={this.transformOrigin}
+                >
+                    {this.props.children}
+                </MaterialPopover>
+            </>
+        );
+    }
+
+    handleClose = () => {
+        this.setState({ anchorEl: undefined });
+    }
+
+    handleTriggerClick = (event: React.MouseEvent<any>) => {
+        this.setState({ anchorEl: event.currentTarget });
+    }
+
+    handleSelfClick = () => {
+        if (this.props.closeOnContentClick) {
+            this.handleClose();
+        }
+    }
+
+}
+
+export const DefaultTrigger: React.SFC<IconButtonProps> = (props) => (
+    <IconButton {...props}>
+        <i className="fas" />
+    </IconButton>
+);
+
+export default Popover;
index 83fb59bd3eb0b4f77854848a3637488ce9894eb2..830621b440d2e1ab1068eaa665ca39a4791d2328 100644 (file)
@@ -9,4 +9,5 @@ export interface Project {
     uuid: string;
     ownerUuid: string;
     href: string;
+    kind: string;
 }
index bb3d0713f82efaee3f4b082b55d926a77bf47bba..119cfece4b8fcb130fa69f4ec54304e2f7d270f5 100644 (file)
@@ -47,7 +47,8 @@ export default class ProjectService {
                     modifiedAt: g.modified_at,
                     href: g.href,
                     uuid: g.uuid,
-                    ownerUuid: g.owner_uuid
+                    ownerUuid: g.owner_uuid,
+                    kind: g.kind
                 } as Project));
                 dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid}));
                 return projects;
index adfce969f6c554c326e74ef0f8e27be00e359a12..cfb73fa8b45daa4be4dbb1a0dfeee5790d5461f3 100644 (file)
@@ -14,7 +14,8 @@ describe('project-reducer', () => {
             createdAt: '2018-01-01',
             modifiedAt: '2018-01-01',
             ownerUuid: 'owner-test123',
-            uuid: 'test123'
+            uuid: 'test123',
+            kind: ""
         };
 
         const state = projectsReducer(initialState, actions.CREATE_PROJECT(project));
@@ -29,7 +30,8 @@ describe('project-reducer', () => {
             createdAt: '2018-01-01',
             modifiedAt: '2018-01-01',
             ownerUuid: 'owner-test123',
-            uuid: 'test123'
+            uuid: 'test123',
+            kind: ""
         };
 
         const projects = [project, project];
index 8aa69ff20ad8b3674455b77d77454b8c9a06b6da..7563ea90c229febd989ae33af0602daea5c42401 100644 (file)
@@ -9,7 +9,7 @@ import * as _ from "lodash";
 
 export type ProjectState = Array<TreeItem<Project>>;
 
-function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
+export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
     let item;
     for (const t of tree) {
         item = t.id === itemId
diff --git a/src/views/data-explorer/data-explorer.tsx b/src/views/data-explorer/data-explorer.tsx
new file mode 100644 (file)
index 0000000..5f17b63
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { DataTableProps } from "../../components/data-table";
+import { RouteComponentProps } from 'react-router';
+import { Project } from '../../models/project';
+import { ProjectState, findTreeItem } from '../../store/project/project-reducer';
+import { RootState } from '../../store/store';
+import { connect, DispatchProp } from 'react-redux';
+import { push } from 'react-router-redux';
+import projectActions from "../../store/project/project-action";
+import { DataExplorer, DataItem } from '../../components/data-explorer';
+import { TreeItem } from '../../components/tree/tree';
+
+interface DataExplorerViewDataProps {
+    projects: ProjectState;
+}
+
+type DataExplorerViewProps = DataExplorerViewDataProps & RouteComponentProps<{ name: string }> & DispatchProp;
+
+type DataExplorerViewState = Pick<DataTableProps<Project>, "columns">;
+
+interface MappedProjectItem extends DataItem {
+    uuid: string;
+}
+
+class DataExplorerView extends React.Component<DataExplorerViewProps, DataExplorerViewState> {
+
+    render() {
+        const project = findTreeItem(this.props.projects, this.props.match.params.name);
+        const projectItems = project && project.items || [];
+        return (
+            <DataExplorer
+                items={projectItems.map(mapTreeItem)}
+                onItemClick={this.goToProject}
+            />
+        );
+    }
+
+    goToProject = (project: MappedProjectItem) => {
+        this.props.dispatch(push(`/project/${project.uuid}`));
+        this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(project.uuid));
+    }
+
+}
+
+const mapTreeItem = (item: TreeItem<Project>): MappedProjectItem => ({
+    name: item.data.name,
+    type: item.data.kind,
+    owner: item.data.ownerUuid,
+    lastModified: item.data.modifiedAt,
+    uuid: item.data.uuid
+});
+
+
+export default connect(
+    (state: RootState) => ({
+        projects: state.projects
+    })
+)(DataExplorerView);
index 8884f3a8d84ad46df4c189aa9b37774bdfc82815..90df260b974d983842f67aa2d5ab90a7b8fd63c8 100644 (file)
@@ -6,23 +6,12 @@ import * as React from 'react';
 
 import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles';
 import Drawer from '@material-ui/core/Drawer';
-import AppBar from '@material-ui/core/AppBar';
-import Toolbar from '@material-ui/core/Toolbar';
-import Typography from '@material-ui/core/Typography';
 import { connect, DispatchProp } from "react-redux";
-import ProjectList from "../../components/project-list/project-list";
 import { Route, Switch } from "react-router";
-import { Link } from "react-router-dom";
-import Button from "@material-ui/core/Button/Button";
 import authActions from "../../store/auth/auth-action";
-import IconButton from "@material-ui/core/IconButton/IconButton";
-import Menu from "@material-ui/core/Menu/Menu";
-import MenuItem from "@material-ui/core/MenuItem/MenuItem";
-import { AccountCircle } from "@material-ui/icons";
 import { User } from "../../models/user";
-import Grid from "@material-ui/core/Grid/Grid";
 import { RootState } from "../../store/store";
-import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItems, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
+import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
 import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs';
 import { push } from 'react-router-redux';
 import projectActions from "../../store/project/project-action";
@@ -30,6 +19,7 @@ import ProjectTree from '../../components/project-tree/project-tree';
 import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
 import { Project } from "../../models/project";
 import { projectService } from '../../services/services';
+import DataExplorer from '../data-explorer/data-explorer';
 
 const drawerWidth = 240;
 
@@ -145,14 +135,17 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
 
     toggleProjectTreeItem = (itemId: string, status: TreeItemStatus) => {
         if (status === TreeItemStatus.Loaded) {
-            this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(itemId));
+            this.openProjectItem(itemId);
         } else {
-            this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => {
-                this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(itemId));
-            });
+            this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => this.openProjectItem(itemId));
         }
     }
 
+    openProjectItem = (itemId: string) => {
+        this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(itemId));
+        this.props.dispatch(push(`/project/${itemId}`));
+    }
+
     render() {
         const { classes, user } = this.props;
         return (
@@ -178,9 +171,10 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                             toggleProjectTreeItem={this.toggleProjectTreeItem} />
                     </Drawer>}
                 <main className={classes.content}>
+                    <div className={classes.toolbar} />
                     <div className={classes.toolbar} />
                     <Switch>
-                        <Route path="/project/:name" component={ProjectList} />
+                        <Route path="/project/:name" component={DataExplorer} />
                     </Switch>
                 </main>
             </div>