#
# SPDX-License-Identifier: Apache-2.0
+# Use bash, and run all lines in each recipe as one shell command
+SHELL := /bin/bash
+.ONESHELL:
+
APP_NAME?=arvados-workbench2
# GIT_TAG is the last tagged stable release (i.e. 1.2.0)
DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
# redHat package file
-RPM_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION).x86_64.rpm
+RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm
export WORKSPACE?=$(shell pwd)
--description="$(DESCRIPTION)" \
$(WORKSPACE)/build/=$(DEST_DIR)
+copy: $(DEB_FILE) $(RPM_FILE)
+ for target in $(TARGETS); do \
+ if [[ $$target =~ ^centos ]]; then
+ cp -p $(RPM_FILE) packages/$$target ; \
+ else
+ cp -p $(DEB_FILE) packages/$$target ; \
+ fi
+ done
+ rm -f $(RPM_FILE)
+ rm -f $(DEB_FILE)
+
# use FPM to create DEB and RPM
-packages: $(DEB_FILE) $(RPM_FILE)
+packages: copy
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export default class Popper {
+ static placements = [
+ 'auto',
+ 'auto-end',
+ 'auto-start',
+ 'bottom',
+ 'bottom-end',
+ 'bottom-start',
+ 'left',
+ 'left-end',
+ 'left-start',
+ 'right',
+ 'right-end',
+ 'right-start',
+ 'top',
+ 'top-end',
+ 'top-start'
+ ];
+
+ constructor() {
+ return {
+ destroy: jest.fn(),
+ scheduleUpdate: jest.fn()
+ };
+ }
+}
\ No newline at end of file
"@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",
--- /dev/null
+// 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
it("renders one item", () => {
const items = [
- {label: 'breadcrumb 1'}
+ { label: 'breadcrumb 1' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
expect(breadcrumbs.find(Button)).toHaveLength(1);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
});
-
+
it("renders multiple items", () => {
const items = [
- {label: 'breadcrumb 1'},
- {label: 'breadcrumb 2'}
+ { label: 'breadcrumb 1' },
+ { label: 'breadcrumb 2' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
expect(breadcrumbs.find(Button)).toHaveLength(2);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
});
-
+
it("calls onClick with clicked item", () => {
const items = [
- {label: 'breadcrumb 1'},
- {label: 'breadcrumb 2'}
+ { label: 'breadcrumb 1' },
+ { label: 'breadcrumb 2' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
breadcrumbs.find(Button).at(1).simulate('click');
expect(onClick).toBeCalledWith(items[1]);
});
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
-import { Button, Grid, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } from '@material-ui/core';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { withStyles } from '@material-ui/core';
}
const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, items }) => {
- return <Grid container alignItems="center">
+ return <Grid container alignItems="center" wrap="nowrap">
{
items.map((item, index) => {
const isLastItem = index === items.length - 1;
return (
<React.Fragment key={index}>
- <Button
- color="inherit"
- className={isLastItem ? classes.currentItem : classes.item}
- onClick={() => onClick(item)}
- >
- {item.label}
- </Button>
+ <Tooltip title={item.label}>
+ <Button
+ color="inherit"
+ className={isLastItem ? classes.currentItem : classes.item}
+ onClick={() => onClick(item)}
+ >
+ <Typography
+ noWrap
+ color="inherit"
+ className={classes.label}
+ >
+ {item.label}
+ </Typography>
+ </Button>
+ </Tooltip>
{
!isLastItem && <ChevronRightIcon color="inherit" className={classes.item} />
}
</Grid>;
};
-type CssRules = "item" | "currentItem";
+type CssRules = "item" | "currentItem" | "label";
const styles: StyleRulesCallback<CssRules> = theme => {
const { unit } = theme.spacing;
},
currentItem: {
opacity: 1
+ },
+ label: {
+ textTransform: "none"
}
};
};
--- /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 * as Adapter from "enzyme-adapter-react-16";
+import ColumnSelector, { ColumnSelectorProps, ColumnSelectorTrigger } from "./column-selector";
+import { DataColumn } from "../data-table/data-column";
+import { ListItem, Checkbox } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<ColumnSelector />", () => {
+ it("shows only configurable columns", () => {
+ const columns: Array<DataColumn<void>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ },
+ {
+ name: "Column 2",
+ render: () => <span />,
+ selected: true,
+ configurable: true,
+ },
+ {
+ name: "Column 3",
+ render: () => <span />,
+ selected: true,
+ configurable: false
+ }
+ ];
+ const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+ columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+ expect(columnsConfigurator.find(ListItem)).toHaveLength(2);
+ });
+
+ it("renders checked checkboxes next to selected columns", () => {
+ const columns: Array<DataColumn<void>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ },
+ {
+ name: "Column 2",
+ render: () => <span />,
+ selected: false
+ },
+ {
+ name: "Column 3",
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
+ columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+ expect(columnsConfigurator.find(Checkbox).at(0).prop("checked")).toBe(true);
+ expect(columnsConfigurator.find(Checkbox).at(1).prop("checked")).toBe(false);
+ expect(columnsConfigurator.find(Checkbox).at(2).prop("checked")).toBe(true);
+ });
+
+ it("calls onColumnToggle with clicked column", () => {
+ const columns: Array<DataColumn<void>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const onColumnToggle = jest.fn();
+ const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={onColumnToggle} />);
+ columnsConfigurator.find(ColumnSelectorTrigger).simulate("click");
+ columnsConfigurator.find(ListItem).simulate("click");
+ expect(onColumnToggle).toHaveBeenCalledWith(columns[0]);
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithStyles, StyleRulesCallback, Theme, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem } from '@material-ui/core';
+import MenuIcon from "@material-ui/icons/Menu";
+import { DataColumn, isColumnConfigurable } from '../data-table/data-column';
+import Popover from "../popover/popover";
+import { IconButtonProps } from '@material-ui/core/IconButton';
+
+export interface ColumnSelectorProps {
+ columns: Array<DataColumn<any>>;
+ onColumnToggle: (column: DataColumn<any>) => void;
+}
+
+const ColumnSelector: React.SFC<ColumnSelectorProps & WithStyles<CssRules>> = ({ columns, onColumnToggle, classes }) =>
+ <Popover triggerComponent={ColumnSelectorTrigger}>
+ <Paper>
+ <List dense>
+ {columns
+ .filter(isColumnConfigurable)
+ .map((column, index) => (
+ <ListItem
+ button
+ key={index}
+ onClick={() => onColumnToggle(column)}>
+ <Checkbox
+ disableRipple
+ color="primary"
+ checked={column.selected}
+ className={classes.checkbox} />
+ <ListItemText>
+ {column.name}
+ </ListItemText>
+ </ListItem>
+ ))}
+ </List>
+ </Paper>
+ </Popover>;
+
+export const ColumnSelectorTrigger: React.SFC<IconButtonProps> = (props) =>
+ <IconButton {...props}>
+ <MenuIcon />
+ </IconButton>;
+
+type CssRules = "checkbox";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+ checkbox: {
+ width: 24,
+ height: 24
+ }
+});
+
+export default withStyles(styles)(ColumnSelector);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure, shallow } from "enzyme";
+import * as Adapter from "enzyme-adapter-react-16";
+import ContextMenu from "./context-menu";
+import { ListItem } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<ContextMenu />", () => {
+ const actions = [[{
+ icon: "",
+ name: "Action 1.1"
+ }, {
+ icon: "",
+ name: "Action 1.2"
+ },], [{
+ icon: "",
+ name: "Action 2.1"
+ }]];
+
+ it("calls onActionClick with clicked action", () => {
+ const onActionClick = jest.fn();
+ const contextMenu = mount(<ContextMenu
+ anchorEl={document.createElement("div")}
+ onClose={jest.fn()}
+ onActionClick={onActionClick}
+ actions={actions} />);
+ contextMenu.find(ListItem).at(2).simulate("click");
+ expect(onActionClick).toHaveBeenCalledWith(actions[1][0]);
+ });
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import * as React from "react";
+import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
+import { DefaultTransformOrigin } from "../popover/helpers";
+
+export interface ContextMenuAction {
+ name: string;
+ icon: string;
+}
+
+export type ContextMenuActionGroup = ContextMenuAction[];
+
+export interface ContextMenuProps<T> {
+ anchorEl?: HTMLElement;
+ actions: ContextMenuActionGroup[];
+ onActionClick: (action: ContextMenuAction) => void;
+ onClose: () => void;
+}
+
+export default class ContextMenu<T> extends React.PureComponent<ContextMenuProps<T>> {
+ render() {
+ const { anchorEl, actions, onClose, onActionClick } = this.props;
+ return <Popover
+ anchorEl={anchorEl}
+ open={!!anchorEl}
+ onClose={onClose}
+ transformOrigin={DefaultTransformOrigin}
+ anchorOrigin={DefaultTransformOrigin}>
+ <List dense>
+ {actions.map((group, groupIndex) =>
+ <React.Fragment key={groupIndex}>
+ {group.map((action, actionIndex) =>
+ <ListItem
+ button
+ key={actionIndex}
+ onClick={() => onActionClick(action)}>
+ <ListItemIcon>
+ <i className={action.icon} />
+ </ListItemIcon>
+ <ListItemText>
+ {action.name}
+ </ListItemText>
+ </ListItem>)}
+ {groupIndex < actions.length - 1 && <Divider />}
+ </React.Fragment>)}
+ </List>
+ </Popover>;
+ }
+}
--- /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")).toBe(columns);
+ expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
+ dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
+ dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange");
+ dataExplorer.find(DataTable).prop("onSortToggle")("sortToggle");
+ expect(onFiltersChange).toHaveBeenCalledWith("filtersChange");
+ expect(onSortToggle).toHaveBeenCalledWith("sortToggle");
+ expect(onRowClick).toHaveBeenCalledWith("rowClick");
+ });
+
+ it("does not render <SearchInput/>, <ColumnSelector/> and <TablePagination/> if there is no items", () => {
+ const dataExplorer = mount(<DataExplorer
+ {...mockDataExplorerProps()}
+ items={[]}
+ />);
+ expect(dataExplorer.find(SearchInput)).toHaveLength(0);
+ expect(dataExplorer.find(ColumnSelector)).toHaveLength(0);
+ expect(dataExplorer.find(TablePagination)).toHaveLength(0);
+ });
+
+ it("communicates with <TablePagination/>", () => {
+ const onChangePage = jest.fn();
+ const onChangeRowsPerPage = jest.fn();
+ const dataExplorer = mount(<DataExplorer
+ {...mockDataExplorerProps()}
+ items={["Item 1"]}
+ page={10}
+ rowsPerPage={50}
+ onChangePage={onChangePage}
+ onChangeRowsPerPage={onChangeRowsPerPage}
+ />);
+ expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
+ expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
+ dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
+ dataExplorer.find(TablePagination).prop("onChangeRowsPerPage")({ target: { value: 10 } });
+ expect(onChangePage).toHaveBeenCalledWith(6);
+ expect(onChangeRowsPerPage).toHaveBeenCalledWith(10);
+ });
+});
+
+const mockDataExplorerProps = () => ({
+ columns: [],
+ items: [],
+ contextActions: [],
+ searchValue: "",
+ page: 0,
+ rowsPerPage: 0,
+ onSearch: jest.fn(),
+ onFiltersChange: jest.fn(),
+ onSortToggle: jest.fn(),
+ onRowClick: jest.fn(),
+ onColumnToggle: jest.fn(),
+ onContextAction: jest.fn(),
+ onChangePage: jest.fn(),
+ onChangeRowsPerPage: jest.fn()
+});
\ No newline at end of file
--- /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);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
+
+export interface DataColumn<T> {
+ name: string;
+ selected: boolean;
+ configurable?: boolean;
+ key?: React.Key;
+ sortDirection?: SortDirection;
+ filters?: DataTableFilterItem[];
+ render: (item: T) => React.ReactElement<void>;
+ renderHeader?: () => React.ReactElement<void> | null;
+}
+
+export type SortDirection = "asc" | "desc" | "none";
+
+export const isColumnConfigurable = <T>(column: DataColumn<T>) => {
+ return column.configurable === undefined || column.configurable;
+};
+
+export const toggleSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
+ return column.sortDirection
+ ? column.sortDirection === "asc"
+ ? { ...column, sortDirection: "desc" }
+ : { ...column, sortDirection: "asc" }
+ : column;
+};
+
+export const resetSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
+ return column.sortDirection ? { ...column, sortDirection: "none" } : column;
+};
--- /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 { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
+import * as Adapter from "enzyme-adapter-react-16";
+import DataTable from "./data-table";
+import { DataColumn } from "./data-column";
+import DataTableFilters from "../data-table-filters/data-table-filters";
+
+configure({ adapter: new Adapter() });
+
+describe("<DataTable />", () => {
+ it("shows only selected columns", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ },
+ {
+ name: "Column 2",
+ render: () => <span />,
+ selected: true
+ },
+ {
+ name: "Column 3",
+ render: () => <span />,
+ selected: false
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
+ });
+
+ it("renders column name", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
+ });
+
+ it("uses renderHeader instead of name prop", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ renderHeader: () => <span>Column Header</span>,
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
+ });
+
+ it("passes column key prop to corresponding cells", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ key: "column-1-key",
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
+ expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
+ });
+
+ it("shows information that items array is empty", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ render: () => <span />,
+ selected: true
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={[]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(Typography).text()).toBe("No items");
+ });
+
+ it("renders items", () => {
+ const columns: Array<DataColumn<string>> = [
+ {
+ name: "Column 1",
+ render: (item) => <Typography>{item}</Typography>,
+ selected: true
+ },
+ {
+ name: "Column 2",
+ render: (item) => <Button>{item}</Button>,
+ selected: true
+ }
+ ];
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()} />);
+ expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
+ expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
+ });
+
+ it("passes sorting props to <TableSortLabel />", () => {
+ const columns: Array<DataColumn<string>> = [{
+ name: "Column 1",
+ sortDirection: "asc",
+ selected: true,
+ render: (item) => <Typography>{item}</Typography>
+ }];
+ const onSortToggle = jest.fn();
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={jest.fn()}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={onSortToggle}/>);
+ expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
+ dataTable.find(TableSortLabel).at(0).simulate("click");
+ expect(onSortToggle).toHaveBeenCalledWith(columns[0]);
+ });
+
+ it("passes filter props to <DataTableFilter />", () => {
+ const columns: Array<DataColumn<string>> = [{
+ name: "Column 1",
+ sortDirection: "asc",
+ selected: true,
+ filters: [{name: "Filter 1", selected: true}],
+ render: (item) => <Typography>{item}</Typography>
+ }];
+ const onFiltersChange = jest.fn();
+ const dataTable = mount(<DataTable
+ columns={columns}
+ items={["item 1"]}
+ onFiltersChange={onFiltersChange}
+ onRowClick={jest.fn()}
+ onRowContextMenu={jest.fn()}
+ onSortToggle={jest.fn()}/>);
+ expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters);
+ dataTable.find(DataTableFilters).prop("onChange")([]);
+ expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
+ });
+
+
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, Typography } from '@material-ui/core';
+import { DataColumn, SortDirection } from './data-column';
+import DataTableFilters, { DataTableFilterItem } from "../data-table-filters/data-table-filters";
+
+export type DataColumns<T> = Array<DataColumn<T>>;
+
+export interface DataTableProps<T> {
+ items: T[];
+ columns: DataColumns<T>;
+ onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+ onRowContextMenu: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+ onSortToggle: (column: DataColumn<T>) => void;
+ onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
+}
+
+class DataTable<T> extends React.Component<DataTableProps<T> & WithStyles<CssRules>> {
+ render() {
+ const { items, classes } = this.props;
+ return <div className={classes.tableContainer}>
+ {items.length > 0 ?
+ <Table>
+ <TableHead>
+ <TableRow>
+ {this.mapVisibleColumns(this.renderHeadCell)}
+ </TableRow>
+ </TableHead>
+ <TableBody className={classes.tableBody}>
+ {items.map(this.renderBodyRow)}
+ </TableBody>
+ </Table> : <Typography
+ className={classes.noItemsInfo}
+ variant="body2"
+ gutterBottom>
+ No items
+ </Typography>}
+ </div>;
+ }
+
+ renderHeadCell = (column: DataColumn<T>, index: number) => {
+ const { name, key, renderHeader, filters, sortDirection } = column;
+ const { onSortToggle, onFiltersChange } = this.props;
+ return <TableCell key={key || index}>
+ {renderHeader ?
+ renderHeader() :
+ filters
+ ? <DataTableFilters
+ name={`${name} filters`}
+ onChange={filters =>
+ onFiltersChange &&
+ onFiltersChange(filters, column)}
+ filters={filters}>
+ {name}
+ </DataTableFilters>
+ : sortDirection
+ ? <TableSortLabel
+ active={sortDirection !== "none"}
+ direction={sortDirection !== "none" ? sortDirection : undefined}
+ onClick={() =>
+ onSortToggle &&
+ onSortToggle(column)}>
+ {name}
+ </TableSortLabel>
+ : <span>
+ {name}
+ </span>}
+ </TableCell>;
+ }
+
+ renderBodyRow = (item: T, index: number) => {
+ const { columns, onRowClick, onRowContextMenu } = this.props;
+ return <TableRow
+ hover
+ key={index}
+ onClick={event => onRowClick && onRowClick(event, item)}
+ onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}>
+ {this.mapVisibleColumns((column, index) => (
+ <TableCell key={column.key || index}>
+ {column.render(item)}
+ </TableCell>
+ ))}
+ </TableRow>;
+ }
+
+ mapVisibleColumns = (fn: (column: DataColumn<T>, index: number) => React.ReactElement<any>) => {
+ return this.props.columns.filter(column => column.selected).map(fn);
+ }
+
+}
+
+type CssRules = "tableBody" | "tableContainer" | "noItemsInfo";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+ tableContainer: {
+ overflowX: 'auto'
+ },
+ tableBody: {
+ background: theme.palette.background.paper
+ },
+ noItemsInfo: {
+ textAlign: "center",
+ padding: theme.spacing.unit
+ }
+});
+
+export default withStyles(styles)(DataTable);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PopoverOrigin } from "@material-ui/core/Popover";
+
+export const mockAnchorFromMouseEvent = (event: React.MouseEvent<HTMLElement>) => {
+ const el = document.createElement('div');
+ const clientRect = {
+ left: event.clientX,
+ right: event.clientX,
+ top: event.clientY,
+ bottom: event.clientY,
+ width: 0,
+ height: 0
+ };
+ el.getBoundingClientRect = () => clientRect;
+ return el;
+};
+
+export const DefaultTransformOrigin: PopoverOrigin = {
+ vertical: "top",
+ horizontal: "right",
+};
\ No newline at end of file
--- /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 * 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
--- /dev/null
+// 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;
--- /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
items?: Array<TreeItem<T>>;
render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
toggleItemOpen: (id: string, status: TreeItemStatus) => void;
- toggleItemActive: (id: string) => void;
+ toggleItemActive: (id: string, status: TreeItemStatus) => void;
level?: number;
}
return <List component="div" className={list}>
{items && items.map((it: TreeItem<T>, idx: number) =>
<div key={`item/${level}/${idx}`}>
- <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id)}>
+ <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)}>
{it.status === TreeItemStatus.Pending ? <CircularProgress size={10} className={loader} /> : null}
{it.toggled && it.items && it.items.length === 0 ? null : this.renderArrow(it.status, it.active ? activeArrow : inactiveArrow, it.open, it.id)}
{render(it, level)}
import createBrowserHistory from "history/createBrowserHistory";
import configureStore from "./store/store";
import { ConnectedRouter } from "react-router-redux";
-import ApiToken from "./components/api-token/api-token";
+import ApiToken from "./views-components/api-token/api-token";
import authActions from "./store/auth/auth-action";
-import { authService, projectService } from "./services/services";
-import { sidePanelData } from './store/side-panel/side-panel-reducer';
+import { authService } from "./services/services";
+import { getProjectList } from "./store/project/project-action";
const history = createBrowserHistory();
store.dispatch(authActions.INIT());
const rootUuid = authService.getRootUuid();
-store.dispatch<any>(projectService.getProjectList(rootUuid));
+store.dispatch<any>(getProjectList(rootUuid));
const App = () =>
<Provider store={store}>
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "./resource";
+
+export interface Collection extends Resource {
+}
//
// SPDX-License-Identifier: AGPL-3.0
-export interface Project {
- name: string;
- createdAt: string;
- modifiedAt: string;
- uuid: string;
- ownerUuid: string;
- href: string;
+import { Resource } from "./resource";
+
+export interface Project extends Resource {
}
--- /dev/null
+export interface Resource {
+ name: string;
+ createdAt: string;
+ modifiedAt: string;
+ uuid: string;
+ ownerUuid: string;
+ href: string;
+ kind: string;
+}
import { API_HOST, serverApi } from "../../common/api/server-api";
import { User } from "../../models/user";
-import { Dispatch } from "redux";
-import actions from "../../store/auth/auth-action";
export const API_TOKEN_KEY = 'apiToken';
export const USER_EMAIL_KEY = 'userEmail';
window.location.assign(`${API_HOST}/logout?return_to=${currentUrl}`);
}
- public getUserDetails = () => (dispatch: Dispatch): Promise<void> => {
- dispatch(actions.USER_DETAILS_REQUEST());
+ public getUserDetails = (): Promise<User> => {
return serverApi
.get<UserDetailsResponse>('/users/current')
- .then(resp => {
- dispatch(actions.USER_DETAILS_SUCCESS(resp.data));
- });
+ .then(resp => ({
+ email: resp.data.email,
+ firstName: resp.data.first_name,
+ lastName: resp.data.last_name,
+ uuid: resp.data.uuid,
+ ownerUuid: resp.data.owner_uuid
+ }));
}
public getRootUuid() {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { serverApi } from "../../common/api/server-api";
+import FilterBuilder, { FilterField } from "../../common/api/filter-builder";
+import { ArvadosResource } from "../response";
+import { Collection } from "../../models/collection";
+
+interface CollectionResource extends ArvadosResource {
+ name: string;
+ description: string;
+ properties: any;
+ portable_data_hash: string;
+ manifest_text: string;
+ replication_desired: number;
+ replication_confirmed: number;
+ replication_confirmed_at: string;
+ trash_at: string;
+ delete_at: string;
+ is_trashed: boolean;
+}
+
+interface CollectionsResponse {
+ offset: number;
+ limit: number;
+ items: CollectionResource[];
+}
+
+export default class CollectionService {
+ public getCollectionList = (parentUuid?: string): Promise<Collection[]> => {
+ if (parentUuid) {
+ const fb = new FilterBuilder();
+ fb.addLike(FilterField.OWNER_UUID, parentUuid);
+ return serverApi.get<CollectionsResponse>('/collections', { params: {
+ filters: fb.get()
+ }}).then(resp => {
+ const collections = resp.data.items.map(g => ({
+ name: g.name,
+ createdAt: g.created_at,
+ modifiedAt: g.modified_at,
+ href: g.href,
+ uuid: g.uuid,
+ ownerUuid: g.owner_uuid,
+ kind: g.kind
+ } as Collection));
+ return collections;
+ });
+ } else {
+ return Promise.resolve([]);
+ }
+ }
+}
import { serverApi } from "../../common/api/server-api";
import { Dispatch } from "redux";
-import actions from "../../store/project/project-action";
import { Project } from "../../models/project";
-import UrlBuilder from "../../common/api/url-builder";
import FilterBuilder, { FilterField } from "../../common/api/filter-builder";
+import { ArvadosResource } from "../response";
+
+interface GroupResource extends ArvadosResource {
+ name: string;
+ group_class: string;
+ description: string;
+ writable_by: string[];
+ delete_at: string;
+ trash_at: string;
+ is_trashed: boolean;
+}
interface GroupsResponse {
offset: number;
limit: number;
- items: Array<{
- href: string;
- kind: string;
- etag: string;
- uuid: string;
- owner_uuid: string;
- created_at: string;
- modified_by_client_uuid: string;
- modified_by_user_uuid: string;
- modified_at: string;
- name: string;
- group_class: string;
- description: string;
- writable_by: string[];
- delete_at: string;
- trash_at: string;
- is_trashed: boolean;
- }>;
+ items: GroupResource[];
}
export default class ProjectService {
- public getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Project[]> => {
- dispatch(actions.PROJECTS_REQUEST(parentUuid));
+ public getProjectList = (parentUuid?: string): Promise<Project[]> => {
if (parentUuid) {
const fb = new FilterBuilder();
fb.addLike(FilterField.OWNER_UUID, parentUuid);
return serverApi.get<GroupsResponse>('/groups', { params: {
filters: fb.get()
- }}).then(groups => {
- const projects = groups.data.items.map(g => ({
+ }}).then(resp => {
+ const projects = resp.data.items.map(g => ({
name: g.name,
createdAt: g.created_at,
modifiedAt: g.modified_at,
href: g.href,
uuid: g.uuid,
- ownerUuid: g.owner_uuid
+ ownerUuid: g.owner_uuid,
+ kind: g.kind
} as Project));
- dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid}));
return projects;
});
} else {
- dispatch(actions.PROJECTS_SUCCESS({projects: [], parentItemId: parentUuid}));
return Promise.resolve([]);
}
}
--- /dev/null
+export interface ArvadosResource {
+ uuid: string;
+ owner_uuid: string;
+ created_at: string;
+ modified_by_client_uuid: string;
+ modified_by_user_uuid: string;
+ modified_at: string;
+ href: string;
+ kind: string;
+ etag: string;
+}
import AuthService from "./auth-service/auth-service";
import ProjectService from "./project-service/project-service";
+import CollectionService from "./collection-service/collection-service";
export const authService = new AuthService();
export const projectService = new ProjectService();
+export const collectionService = new CollectionService();
// SPDX-License-Identifier: AGPL-3.0
import { ofType, default as unionize, UnionOf } from "unionize";
-import { UserDetailsResponse } from "../../services/auth-service/auth-service";
+import { Dispatch } from "redux";
+import { authService } from "../../services/services";
+import { User } from "../../models/user";
const actions = unionize({
SAVE_API_TOKEN: ofType<string>(),
LOGOUT: {},
INIT: {},
USER_DETAILS_REQUEST: {},
- USER_DETAILS_SUCCESS: ofType<UserDetailsResponse>()
+ USER_DETAILS_SUCCESS: ofType<User>()
}, {
tag: 'type',
value: 'payload'
});
+export const getUserDetails = () => (dispatch: Dispatch): Promise<User> => {
+ dispatch(actions.USER_DETAILS_REQUEST());
+ return authService.getUserDetails().then(details => {
+ dispatch(actions.USER_DETAILS_SUCCESS(details));
+ return details;
+ });
+};
+
+
export type AuthAction = UnionOf<typeof actions>;
export default actions;
it('should set user details on success fetch', () => {
const initialState = undefined;
- const userDetails = {
+ const user = {
email: "test@test.com",
- first_name: "John",
- last_name: "Doe",
+ firstName: "John",
+ lastName: "Doe",
uuid: "uuid",
- owner_uuid: "ownerUuid",
- is_admin: true
+ ownerUuid: "ownerUuid"
};
- const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(userDetails));
+ const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(user));
expect(state).toEqual({
apiToken: undefined,
user: {
import { User } from "../../models/user";
import { authService } from "../../services/services";
import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api";
-import { UserDetailsResponse } from "../../services/auth-service/auth-service";
export interface AuthState {
user?: User;
authService.logout();
return {...state, apiToken: undefined};
},
- USER_DETAILS_SUCCESS: (ud: UserDetailsResponse) => {
- const user = {
- email: ud.email,
- firstName: ud.first_name,
- lastName: ud.last_name,
- uuid: ud.uuid,
- ownerUuid: ud.owner_uuid
- };
+ USER_DETAILS_SUCCESS: (user: User) => {
authService.saveUser(user);
return {...state, user};
},
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Collection } from "../../models/collection";
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+import { collectionService } from "../../services/services";
+
+const actions = unionize({
+ CREATE_COLLECTION: ofType<Collection>(),
+ REMOVE_COLLECTION: ofType<string>(),
+ COLLECTIONS_REQUEST: ofType<any>(),
+ COLLECTIONS_SUCCESS: ofType<{ collections: Collection[] }>(),
+}, {
+ tag: 'type',
+ value: 'payload'
+});
+
+export const getCollectionList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Collection[]> => {
+ dispatch(actions.COLLECTIONS_REQUEST());
+ return collectionService.getCollectionList(parentUuid).then(collections => {
+ dispatch(actions.COLLECTIONS_SUCCESS({collections}));
+ return collections;
+ });
+};
+
+export type CollectionAction = UnionOf<typeof actions>;
+export default actions;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import collectionsReducer from "./collection-reducer";
+import actions from "./collection-action";
+
+describe('collection-reducer', () => {
+ it('should add new collection to the list', () => {
+ const initialState = undefined;
+ const collection = {
+ name: 'test',
+ href: 'href',
+ createdAt: '2018-01-01',
+ modifiedAt: '2018-01-01',
+ ownerUuid: 'owner-test123',
+ uuid: 'test123',
+ kind: ""
+ };
+
+ const state = collectionsReducer(initialState, actions.CREATE_COLLECTION(collection));
+ expect(state).toEqual([collection]);
+ });
+
+ it('should load collections', () => {
+ const initialState = undefined;
+ const collection = {
+ name: 'test',
+ href: 'href',
+ createdAt: '2018-01-01',
+ modifiedAt: '2018-01-01',
+ ownerUuid: 'owner-test123',
+ uuid: 'test123',
+ kind: ""
+ };
+
+ const collections = [collection, collection];
+ const state = collectionsReducer(initialState, actions.COLLECTIONS_SUCCESS({ collections }));
+ expect(state).toEqual([collection, collection]);
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import actions, { CollectionAction } from "./collection-action";
+import { Collection } from "../../models/collection";
+
+export type CollectionState = Collection[];
+
+
+const collectionsReducer = (state: CollectionState = [], action: CollectionAction) => {
+ return actions.match(action, {
+ CREATE_COLLECTION: collection => [...state, collection],
+ REMOVE_COLLECTION: () => state,
+ COLLECTIONS_REQUEST: () => {
+ return state;
+ },
+ COLLECTIONS_SUCCESS: ({ collections }) => {
+ return collections;
+ },
+ default: () => state
+ });
+};
+
+export default collectionsReducer;
import { default as unionize, ofType, UnionOf } from "unionize";
import { Project } from "../../models/project";
+import { projectService } from "../../services/services";
+import { Dispatch } from "redux";
const actions = unionize({
CREATE_PROJECT: ofType<Project>(),
REMOVE_PROJECT: ofType<string>(),
- PROJECTS_REQUEST: ofType<any>(),
+ PROJECTS_REQUEST: ofType<string>(),
PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(),
TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType<string>(),
TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType<string>(),
RESET_PROJECT_TREE_ACTIVITY: ofType<string>(),
}, {
- tag: 'type',
- value: 'payload'
-});
+ tag: 'type',
+ value: 'payload'
+ });
+
+export const getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Project[]> => {
+ if (parentUuid) {
+ dispatch(actions.PROJECTS_REQUEST(parentUuid));
+ return projectService.getProjectList(parentUuid).then(projects => {
+ dispatch(actions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
+ return projects;
+ });
+ } return Promise.resolve([]);
+};
export type ProjectAction = UnionOf<typeof actions>;
export default actions;
//
// SPDX-License-Identifier: AGPL-3.0
-import projectsReducer from "./project-reducer";
+import projectsReducer, { getTreePath } from "./project-reducer";
import actions from "./project-action";
+import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
describe('project-reducer', () => {
it('should add new project to the list', () => {
createdAt: '2018-01-01',
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
- uuid: 'test123'
+ uuid: 'test123',
+ kind: ""
};
const state = projectsReducer(initialState, actions.CREATE_PROJECT(project));
createdAt: '2018-01-01',
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
- uuid: 'test123'
+ uuid: 'test123',
+ kind: ""
};
const projects = [project, project];
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: true,
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: true,
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: true,
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: true,
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: true,
modifiedAt: '2018-01-01',
ownerUuid: 'owner-test123',
uuid: 'test123',
+ kind: 'example'
},
id: "1",
open: false,
expect(state).toEqual(project);
});
});
+
+describe("findTreeBranch", () => {
+
+ const createTreeItem = (id: string, items?: Array<TreeItem<string>>): TreeItem<string> => ({
+ id,
+ items,
+ active: false,
+ data: "",
+ open: false,
+ status: TreeItemStatus.Initial
+ });
+
+ it("should return an array that matches path to the given item", () => {
+ const tree: Array<TreeItem<string>> = [
+ createTreeItem("1", [
+ createTreeItem("1.1", [
+ createTreeItem("1.1.1"),
+ createTreeItem("1.1.2")
+ ])
+ ]),
+ createTreeItem("2", [
+ createTreeItem("2.1", [
+ createTreeItem("2.1.1"),
+ createTreeItem("2.1.2")
+ ])
+ ])
+ ];
+ const branch = getTreePath(tree, "2.1.1");
+ expect(branch.map(item => item.id)).toEqual(["2", "2.1", "2.1.1"]);
+ });
+
+ it("should return empty array if item is not found", () => {
+ const tree: Array<TreeItem<string>> = [
+ createTreeItem("1", [
+ createTreeItem("1.1", [
+ createTreeItem("1.1.1"),
+ createTreeItem("1.1.2")
+ ])
+ ]),
+ createTreeItem("2", [
+ createTreeItem("2.1", [
+ createTreeItem("2.1.1"),
+ createTreeItem("2.1.2")
+ ])
+ ])
+ ];
+ expect(getTreePath(tree, "3")).toHaveLength(0);
+ });
+
+});
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
return item;
}
+export function getTreePath<T>(tree: Array<TreeItem<T>>, itemId: string): Array<TreeItem<T>> {
+ for(const item of tree){
+ if(item.id === itemId){
+ return [item];
+ } else {
+ const branch = getTreePath(item.items || [], itemId);
+ if(branch.length > 0){
+ return [item, ...branch];
+ }
+ }
+ }
+ return [];
+}
+
function resetTreeActivity<T>(tree: Array<TreeItem<T>>) {
for (const t of tree) {
t.active = false;
import projectsReducer, { ProjectState } from "./project/project-reducer";
import sidePanelReducer, { SidePanelState } from './side-panel/side-panel-reducer';
import authReducer, { AuthState } from "./auth/auth-reducer";
+import collectionsReducer from "./collection/collection-reducer";
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
const rootReducer = combineReducers({
auth: authReducer,
projects: projectsReducer,
+ collections: collectionsReducer,
router: routerReducer,
sidePanel: sidePanelReducer
});
import { Redirect, RouteProps } from "react-router";
import * as React from "react";
import { connect, DispatchProp } from "react-redux";
-import authActions from "../../store/auth/auth-action";
-import { authService, projectService } from "../../services/services";
+import authActions, { getUserDetails } from "../../store/auth/auth-action";
+import { authService } from "../../services/services";
+import { getProjectList } from "../../store/project/project-action";
interface ApiTokenProps {
}
const search = this.props.location ? this.props.location.search : "";
const apiToken = ApiToken.getUrlParameter(search, 'api_token');
this.props.dispatch(authActions.SAVE_API_TOKEN(apiToken));
- this.props.dispatch<any>(authService.getUserDetails()).then(() => {
+ this.props.dispatch<any>(getUserDetails()).then(() => {
const rootUuid = authService.getRootUuid();
- this.props.dispatch(projectService.getProjectList(rootUuid));
+ this.props.dispatch(getProjectList(rootUuid));
});
}
render() {
import { mount, configure, ReactWrapper } from "enzyme";
import * as Adapter from "enzyme-adapter-react-16";
import MainAppBar from "./main-app-bar";
-import SearchBar from "./search-bar/search-bar";
-import Breadcrumbs from "../breadcrumbs/breadcrumbs";
-import DropdownMenu from "./dropdown-menu/dropdown-menu";
+import SearchBar from "../../components/search-bar/search-bar";
+import Breadcrumbs from "../../components/breadcrumbs/breadcrumbs";
+import DropdownMenu from "../../components/dropdown-menu/dropdown-menu";
import { Button, MenuItem, IconButton } from "@material-ui/core";
import { User } from "../../models/user";
mainAppBar.find(DropdownMenu).at(0).find(MenuItem).at(1).simulate("click");
expect(onMenuItemClick).toBeCalledWith(menuItems.accountMenu[0]);
});
-});
\ No newline at end of file
+});
import NotificationsIcon from "@material-ui/icons/Notifications";
import PersonIcon from "@material-ui/icons/Person";
import HelpIcon from "@material-ui/icons/Help";
-import SearchBar from "./search-bar/search-bar";
-import Breadcrumbs, { Breadcrumb } from "../breadcrumbs/breadcrumbs";
-import DropdownMenu from "./dropdown-menu/dropdown-menu";
+import SearchBar from "../../components/search-bar/search-bar";
+import Breadcrumbs, { Breadcrumb } from "../../components/breadcrumbs/breadcrumbs";
+import DropdownMenu from "../../components/dropdown-menu/dropdown-menu";
import { User, getUserFullname } from "../../models/user";
export interface MainAppBarMenuItem {
}
});
-export default withStyles(styles)(MainAppBar);
\ No newline at end of file
+export default withStyles(styles)(MainAppBar);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface ProjectExplorerItem {
+ uuid: string;
+ name: string;
+ type: string;
+ owner: string;
+ lastModified: string;
+ fileSize?: number;
+ status?: string;
+}
--- /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';
+
+export interface ProjectExplorerContextActions {
+ onAddToFavourite: (item: ProjectExplorerItem) => void;
+ onCopy: (item: ProjectExplorerItem) => void;
+ onDownload: (item: ProjectExplorerItem) => void;
+ onMoveTo: (item: ProjectExplorerItem) => void;
+ onRemove: (item: ProjectExplorerItem) => void;
+ onRename: (item: ProjectExplorerItem) => void;
+ onShare: (item: ProjectExplorerItem) => void;
+}
+
+interface ProjectExplorerProps {
+ items: ProjectExplorerItem[];
+}
+
+interface ProjectExplorerState {
+ columns: Array<DataColumn<ProjectExplorerItem>>;
+ searchValue: string;
+ page: number;
+ rowsPerPage: number;
+}
+
+class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplorerState> {
+ state: ProjectExplorerState = {
+ searchValue: "",
+ page: 0,
+ rowsPerPage: 10,
+ columns: [{
+ name: "Name",
+ selected: true,
+ sortDirection: "asc",
+ render: renderName
+ }, {
+ name: "Status",
+ selected: true,
+ filters: [{
+ name: "In progress",
+ selected: true
+ }, {
+ name: "Complete",
+ selected: true
+ }],
+ render: renderStatus
+ }, {
+ name: "Type",
+ selected: true,
+ filters: [{
+ name: "Collection",
+ selected: true
+ }, {
+ name: "Group",
+ selected: true
+ }],
+ render: item => renderType(item.type)
+ }, {
+ name: "Owner",
+ selected: true,
+ render: item => renderOwner(item.owner)
+ }, {
+ name: "File size",
+ selected: true,
+ sortDirection: "none",
+ render: item => renderFileSize(item.fileSize)
+ }, {
+ name: "Last modified",
+ selected: true,
+ render: item => renderDate(item.lastModified)
+ }]
+ };
+
+ contextMenuActions = [[{
+ icon: "fas fa-users fa-fw",
+ name: "Share"
+ }, {
+ icon: "fas fa-sign-out-alt fa-fw",
+ name: "Move to"
+ }, {
+ icon: "fas fa-star fa-fw",
+ name: "Add to favourite"
+ }, {
+ icon: "fas fa-edit fa-fw",
+ name: "Rename"
+ }, {
+ icon: "fas fa-copy fa-fw",
+ name: "Make a copy"
+ }, {
+ icon: "fas fa-download fa-fw",
+ name: "Download"
+ }], [{
+ icon: "fas fa-trash-alt fa-fw",
+ name: "Remove"
+ }
+ ]];
+
+ render() {
+ return <DataExplorer
+ items={this.props.items}
+ columns={this.state.columns}
+ contextActions={this.contextMenuActions}
+ searchValue={this.state.searchValue}
+ page={this.state.page}
+ rowsPerPage={this.state.rowsPerPage}
+ onColumnToggle={this.toggleColumn}
+ onFiltersChange={this.changeFilters}
+ onRowClick={console.log}
+ onSortToggle={this.toggleSort}
+ onSearch={this.search}
+ onContextAction={this.executeAction}
+ onChangePage={this.changePage}
+ onChangeRowsPerPage={this.changeRowsPerPage} />;
+ }
+
+ toggleColumn = (toggledColumn: DataColumn<ProjectExplorerItem>) => {
+ this.setState({
+ columns: this.state.columns.map(column =>
+ column.name === toggledColumn.name
+ ? { ...column, selected: !column.selected }
+ : column
+ )
+ });
+ }
+
+ toggleSort = (toggledColumn: DataColumn<ProjectExplorerItem>) => {
+ this.setState({
+ columns: this.state.columns.map(column =>
+ column.name === toggledColumn.name
+ ? toggleSortDirection(column)
+ : resetSortDirection(column)
+ )
+ });
+ }
+
+ changeFilters = (filters: DataTableFilterItem[], updatedColumn: DataColumn<ProjectExplorerItem>) => {
+ this.setState({
+ columns: this.state.columns.map(column =>
+ column.name === updatedColumn.name
+ ? { ...column, filters }
+ : column
+ )
+ });
+ }
+
+ executeAction = (action: ContextMenuAction, item: ProjectExplorerItem) => {
+ alert(`Executing ${action.name} on ${item.name}`);
+ }
+
+ search = (searchValue: string) => {
+ this.setState({ searchValue });
+ }
+
+ changePage = (page: number) => {
+ this.setState({ page });
+ }
+
+ changeRowsPerPage = (rowsPerPage: number) => {
+ this.setState({ rowsPerPage });
+ }
+}
+
+const renderName = (item: ProjectExplorerItem) =>
+ <Grid
+ container
+ alignItems="center"
+ wrap="nowrap"
+ spacing={16}>
+ <Grid item>
+ {renderIcon(item)}
+ </Grid>
+ <Grid item>
+ <Typography color="primary">
+ {item.name}
+ </Typography>
+ </Grid>
+ </Grid>;
+
+const renderIcon = (item: ProjectExplorerItem) => {
+ switch (item.type) {
+ case "arvados#group":
+ return <i className="fas fa-folder fa-lg" />;
+ case "arvados#groupList":
+ return <i className="fas fa-th fa-lg" />;
+ default:
+ return <i />;
+ }
+};
+
+const renderDate = (date: string) =>
+ <Typography noWrap>
+ {formatDate(date)}
+ </Typography>;
+
+const renderFileSize = (fileSize?: number) =>
+ <Typography noWrap>
+ {formatFileSize(fileSize)}
+ </Typography>;
+
+const renderOwner = (owner: string) =>
+ <Typography noWrap color="primary">
+ {owner}
+ </Typography>;
+
+const renderType = (type: string) =>
+ <Typography noWrap>
+ {type}
+ </Typography>;
+
+const renderStatus = (item: ProjectExplorerItem) =>
+ <Typography noWrap align="center">
+ {item.status || "-"}
+ </Typography>;
+
+export default ProjectExplorer;
import CircularProgress from '@material-ui/core/CircularProgress';
import ProjectTree from './project-tree';
-import { TreeItem } from '../tree/tree';
+import { TreeItem } from '../../components/tree/tree';
import { Project } from '../../models/project';
Enzyme.configure({ adapter: new Adapter() });
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 ListItemIcon from '@material-ui/core/ListItemIcon';
import Typography from '@material-ui/core/Typography';
-import Tree, { TreeItem, TreeItemStatus } from '../tree/tree';
+import Tree, { TreeItem, TreeItemStatus } from '../../components/tree/tree';
import { Project } from '../../models/project';
export interface ProjectTreeProps {
projects: Array<TreeItem<Project>>;
toggleOpen: (id: string, status: TreeItemStatus) => void;
- toggleActive: (id: string) => void;
+ toggleActive: (id: string, status: TreeItemStatus) => void;
}
class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { TreeItem } from "../../components/tree/tree";
+import { Project } from "../../models/project";
+import { ProjectExplorerItem } from "../../views-components/project-explorer/project-explorer-item";
+
+export const mapProjectTreeItem = (item: TreeItem<Project>): ProjectExplorerItem => ({
+ name: item.data.name,
+ type: item.data.kind,
+ owner: item.data.ownerUuid,
+ lastModified: item.data.modifiedAt,
+ uuid: item.data.uuid
+});
--- /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-dom';
+import { DispatchProp, connect } from 'react-redux';
+import { ProjectState, findTreeItem } from '../../store/project/project-reducer';
+import ProjectExplorer from '../../views-components/project-explorer/project-explorer';
+import { RootState } from '../../store/store';
+import { mapProjectTreeItem } from './project-panel-selectors';
+
+interface ProjectPanelDataProps {
+ projects: ProjectState;
+}
+
+type ProjectPanelProps = ProjectPanelDataProps & RouteComponentProps<{ name: string }> & DispatchProp;
+
+class ProjectPanel extends React.Component<ProjectPanelProps> {
+
+ render() {
+ const project = findTreeItem(this.props.projects, this.props.match.params.name);
+ const projectItems = project && project.items || [];
+ return (
+ <ProjectExplorer items={projectItems.map(mapProjectTreeItem)} />
+ );
+ }
+}
+
+export default connect(
+ (state: RootState) => ({
+ projects: state.projects
+ })
+)(ProjectPanel);
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}>
import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
import { connect, DispatchProp } from "react-redux";
-
-import ProjectList from "../../components/project-list/project-list";
import { Route, Switch } from "react-router";
import authActions from "../../store/auth/auth-action";
import { User } from "../../models/user";
import { RootState } from "../../store/store";
-import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
+import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../views-components/main-app-bar/main-app-bar';
import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs';
import { push } from 'react-router-redux';
-import projectActions from "../../store/project/project-action";
-import sidePanelActions from '../../store/side-panel/side-panel-action';
-import ProjectTree from '../../components/project-tree/project-tree';
+import projectActions, { getProjectList } from "../../store/project/project-action";
+import ProjectTree from '../../views-components/project-tree/project-tree';
import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
import { Project } from "../../models/project";
+import { getTreePath } from '../../store/project/project-reducer';
+import ProjectPanel from '../project-panel/project-panel';
+import sidePanelActions from '../../store/side-panel/side-panel-action';
import { projectService } from '../../services/services';
import SidePanel, { SidePanelItem } from '../../components/side-panel/side-panel';
const drawerWidth = 240;
+const appBarHeight = 102;
-type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'toolbar';
+type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar';
const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column',
},
- content: {
- flexGrow: 1,
+ contentWrapper: {
backgroundColor: theme.palette.background.default,
- padding: theme.spacing.unit * 3,
- height: '100%',
+ display: "flex",
+ flexGrow: 1,
minWidth: 0,
+ paddingTop: appBarHeight
+ },
+ content: {
+ padding: theme.spacing.unit * 3,
+ overflowY: "auto",
+ flexGrow: 1
},
toolbar: theme.mixins.toolbar
});
type WorkbenchProps = WorkbenchDataProps & WorkbenchActionProps & DispatchProp & WithStyles<CssRules>;
interface NavBreadcrumb extends Breadcrumb {
- path: string;
+ itemId: string;
+ status: TreeItemStatus;
}
interface NavMenuItem extends MainAppBarMenuItem {
state = {
anchorEl: null,
searchText: "",
- breadcrumbs: [
- {
- label: "Projects",
- path: "/projects"
- }, {
- label: "Project 1",
- path: "/projects/project-1"
- }
- ],
+ breadcrumbs: [],
menuItems: {
accountMenu: [
{
mainAppBarActions: MainAppBarActionProps = {
- onBreadcrumbClick: (breadcrumb: NavBreadcrumb) => this.props.dispatch(push(breadcrumb.path)),
+ onBreadcrumbClick: ({ itemId, status }: NavBreadcrumb) => {
+ this.toggleProjectTreeItemOpen(itemId, status);
+ },
onSearch: searchText => {
this.setState({ searchText });
this.props.dispatch(push(`/search?q=${searchText}`));
toggleProjectTreeItemOpen = (itemId: string, status: TreeItemStatus) => {
if (status === TreeItemStatus.Loaded) {
+ this.openProjectItem(itemId);
this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
} else {
- this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => {
- this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
- this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
- });
+ this.props.dispatch<any>(getProjectList(itemId))
+ .then(() => {
+ this.openProjectItem(itemId);
+ this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId));
+ this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+ });
}
}
- toggleProjectTreeItemActive = (itemId: string) => {
- this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
- this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+ toggleProjectTreeItemActive = (itemId: string, status: TreeItemStatus) => {
+ if (status === TreeItemStatus.Loaded) {
+ this.openProjectItem(itemId);
+ this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+ this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+ } else {
+ this.props.dispatch<any>(getProjectList(itemId))
+ .then(() => {
+ this.openProjectItem(itemId);
+ this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+ this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId));
+ });
+ }
}
toggleSidePanelOpen = (itemId: string) => {
this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
}
+ openProjectItem = (itemId: string) => {
+ const branch = getTreePath(this.props.projects, itemId);
+ this.setState({
+ breadcrumbs: branch.map(item => ({
+ label: item.data.name,
+ itemId: item.data.uuid,
+ status: item.status
+ }))
+ });
+ this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId));
+ this.props.dispatch(push(`/project/${itemId}`));
+ }
+
render() {
const { classes, user, projects, sidePanelItems } = this.props;
return (
paper: classes.drawerPaper,
}}>
<div className={classes.toolbar} />
- <SidePanel
- toggleOpen={this.toggleSidePanelOpen}
- toggleActive={this.toggleSidePanelActive}
- sidePanelItems={sidePanelItems}>
- <ProjectTree
- projects={projects}
- toggleOpen={this.toggleProjectTreeItemOpen}
- toggleActive={this.toggleProjectTreeItemActive} />
- </SidePanel>
+ <SidePanel
+ toggleOpen={this.toggleSidePanelOpen}
+ toggleActive={this.toggleSidePanelActive}
+ sidePanelItems={sidePanelItems}>
+ <ProjectTree
+ projects={projects}
+ toggleOpen={this.toggleProjectTreeItemOpen}
+ toggleActive={this.toggleProjectTreeItemActive} />
+ </SidePanel>
</Drawer>}
- <main className={classes.content}>
- <div className={classes.toolbar} />
- <Switch>
- <Route path="/project/:name" component={ProjectList} />
- </Switch>
+ <main className={classes.contentWrapper}>
+ <div className={classes.content}>
+ <Switch>
+ <Route path="/project/:name" component={ProjectPanel} />
+ </Switch>
+ </div>
</main>
</div>
);
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"