"@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",
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
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}>
<ListItem
button
key={actionIndex}
- onClick={() => item && action.onClick(item)}>
+ onClick={() => onActionClick(action)}>
<ListItemIcon>
<i className={action.icon} />
</ListItemIcon>
--- /dev/null
+// 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
--- /dev/null
+// 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);
--- /dev/null
+// 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
--- /dev/null
+// 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);
//
// 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;
+};
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() });
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>> = [
{
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>> = [
{
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>> = [
{
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>> = [
{
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");
});
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
// 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}
</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";
--- /dev/null
+// 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
--- /dev/null
+// 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
--- /dev/null
+// 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
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,
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)}
<StyledTree
items={it.items}
render={render}
- toggleItem={toggleItem}
+ toggleItemOpen={toggleItemOpen}
+ toggleItemActive={toggleItemActive}
level={level + 1} />
</Collapse>}
</div>)}
}
}
+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;
},
auth: {
user: undefined
- }
+ },
+ sidePanel: []
}, history);
store.dispatch(authActions.INIT());
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) {
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);
}
};
+
+ // 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));
+ // });
+ // }
+ // }
// 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>;
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,
//
// 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>>,
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
});
};
--- /dev/null
+// 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
--- /dev/null
+// 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
--- /dev/null
+// 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;
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 {
projects: ProjectState;
collections: CollectionState;
router: RouterState;
+ sidePanel: SidePanelState;
}
const rootReducer = combineReducers({
auth: authReducer,
projects: projectsReducer,
collections: collectionsReducer,
- router: routerReducer
+ router: routerReducer,
+ sidePanel: sidePanelReducer
});
+++ /dev/null
-// 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;
import { getResourceKind, Resource, ResourceKind } from "../../models/resource";
-export interface DataItem {
+export interface ProjectExplorerItem {
uuid: string;
name: string;
kind: ResourceKind;
--- /dev/null
+// 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;
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);
});
uuid: "uuid",
ownerUuid: "ownerUuid",
href: "href",
+ kind: 'example'
},
id: "3",
open: false,
uuid: "uuid",
ownerUuid: "ownerUuid",
href: "href",
+ kind: 'example'
},
id: "3",
open: false,
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);
});
uuid: "uuid",
ownerUuid: "ownerUuid",
href: "href",
+ kind: 'example'
},
id: "3",
open: true,
uuid: "uuid",
ownerUuid: "ownerUuid",
href: "href",
+ kind: 'example'
},
id: "3",
open: true,
]
}
];
- 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);
});
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);
});
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) => ({
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);
+++ /dev/null
-// 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);
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) {
owner: p.data.ownerUuid,
uuid: p.data.uuid,
lastModified: p.data.modifiedAt
- } as DataItem;
+ } as ProjectExplorerItem;
dataItems.push(item);
});
owner: c.ownerUuid,
uuid: c.uuid,
lastModified: c.modifiedAt
- } as DataItem;
+ } as ProjectExplorerItem;
dataItems.push(item);
});
--- /dev/null
+// 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);
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}>
// 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";
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;
drawerPaper: {
position: 'relative',
width: drawerWidth,
+ display: 'flex',
+ flexDirection: 'column',
},
contentWrapper: {
backgroundColor: theme.palette.background.default,
projects: Array<TreeItem<Project>>;
currentProjectId: string;
user?: User;
+ sidePanelItems: SidePanelItem[];
}
interface WorkbenchActionProps {
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 => ({
{...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>
);
}
}
+/*
+ <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)
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:
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"
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"