--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Button, Grid, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import { withStyles } from '@material-ui/core';
+
+export interface Breadcrumb {
+ label: string
+}
+
+interface BreadcrumbsDataProps {
+ items: Breadcrumb[]
+}
+
+interface BreadcrumbsActionProps {
+ onClick: (breadcrumb: Breadcrumb) => any
+}
+
+type BreadcrumbsProps = BreadcrumbsDataProps & BreadcrumbsActionProps & WithStyles<CssRules>;
+
+class Breadcrumbs extends React.Component<BreadcrumbsProps> {
+
+ render() {
+ const { classes, onClick } = this.props;
+ return <Grid container alignItems="center">
+ {
+ this.getInactiveItems().map((item, index) => (
+ <React.Fragment key={index}>
+ <Button
+ color="inherit"
+ className={classes.inactiveItem}
+ onClick={() => onClick(item)}
+ >
+ {item.label}
+ </Button>
+ <ChevronRightIcon color="inherit" className={classes.inactiveItem} />
+ </React.Fragment>
+ ))
+ }
+ {
+ this.getActiveItem().map((item, index) => (
+ <Button
+ color="inherit"
+ key={index}
+ onClick={() => onClick(item)}
+ >
+ {item.label}
+ </Button>
+ ))
+ }
+ </Grid>
+ }
+
+ getInactiveItems = () => {
+ return this.props.items.slice(0, -1)
+ }
+
+ getActiveItem = () => {
+ return this.props.items.slice(-1)
+ }
+
+}
+
+type CssRules = 'inactiveItem'
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+ const { unit } = theme.spacing
+ return {
+ inactiveItem: {
+ opacity: 0.6
+ }
+ }
+}
+
+export default withStyles(styles)(Breadcrumbs)
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { shallow, configure } from "enzyme";
+import DropdownMenu from "./dropdown-menu";
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+
+import * as Adapter from 'enzyme-adapter-react-16';
+import { MenuItem, IconButton, Menu } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+describe("<DropdownMenu />", () => {
+ it("renders menu icon", () => {
+ const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={ChevronRightIcon} />);
+ expect(dropdownMenu.find(ChevronRightIcon)).toHaveLength(1);
+ });
+
+ it("render menu items", () => {
+ const dropdownMenu = shallow(
+ <DropdownMenu id="test-menu" icon={ChevronRightIcon}>
+ <MenuItem>Item 1</MenuItem>
+ <MenuItem>Item 2</MenuItem>
+ </DropdownMenu>
+ );
+ expect(dropdownMenu.find(MenuItem)).toHaveLength(2);
+ });
+
+ it("opens on menu icon click", () => {
+ const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={ChevronRightIcon} />);
+ dropdownMenu.find(IconButton).simulate("click", {currentTarget: {}});
+ expect(dropdownMenu.state().anchorEl).toBeDefined();
+ });
+
+ it("closes on menu click", () => {
+ const dropdownMenu = shallow(<DropdownMenu id="test-menu" icon={ChevronRightIcon} />);
+ dropdownMenu.find(Menu).simulate("click", {currentTarget: {}});
+ expect(dropdownMenu.state().anchorEl).toBeUndefined();
+ });
+
+});
\ 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 { Button, Grid, StyleRulesCallback, WithStyles, Menu, MenuItem, IconButton } from '@material-ui/core';
+import { PopoverOrigin } from '@material-ui/core/Popover';
+
+
+interface DropdownMenuDataProps {
+ id: string,
+ icon: React.ComponentType,
+}
+
+
+type DropdownMenuProps = DropdownMenuDataProps;
+
+class DropdownMenu extends React.Component<DropdownMenuProps> {
+
+ state = {
+ anchorEl: undefined
+ }
+
+ transformOrigin: PopoverOrigin = {
+ vertical: "top",
+ horizontal: "center"
+ }
+
+ render() {
+ const { icon: Icon, id, children } = this.props;
+ const { anchorEl } = this.state;
+ return (
+ <div>
+ <IconButton
+ aria-owns={anchorEl ? id : undefined}
+ aria-haspopup="true"
+ color="inherit"
+ onClick={this.handleOpen}
+
+ >
+ <Icon />
+ </IconButton>
+ <Menu
+ id={id}
+ anchorEl={anchorEl}
+ open={Boolean(anchorEl)}
+ onClose={this.handleClose}
+ onClick={this.handleClose}
+ anchorOrigin={this.transformOrigin}
+ transformOrigin={this.transformOrigin}
+ >
+ {children}
+ </Menu>
+ </div>
+ )
+ }
+
+ handleClose = () => {
+ this.setState({ anchorEl: undefined })
+ }
+
+ handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
+ this.setState({ anchorEl: event.currentTarget })
+ }
+}
+
+
+export default DropdownMenu
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { AppBar, Toolbar, Typography, Grid, IconButton, Badge, Paper, Input, StyleRulesCallback, withStyles, WithStyles, Button, MenuItem } from "@material-ui/core";
+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 { User } from "../../models/user";
+
+export interface MainAppBarMenuItem {
+ label: string;
+}
+
+export interface MainAppBarMenuItems {
+ accountMenu: MainAppBarMenuItem[];
+ helpMenu: MainAppBarMenuItem[];
+ anonymousMenu: MainAppBarMenuItem[];
+}
+
+interface MainAppBarDataProps {
+ searchText: string;
+ searchDebounce?: number;
+ breadcrumbs: Breadcrumb[];
+ user?: User;
+ menuItems: MainAppBarMenuItems;
+}
+
+export interface MainAppBarActionProps {
+ onSearch: (searchText: string) => void,
+ onBreadcrumbClick: (breadcrumb: Breadcrumb) => void,
+ onMenuItemClick: (menuItem: MainAppBarMenuItem) => void
+}
+
+type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps & WithStyles<CssRules>;
+
+export class MainAppBar extends React.Component<MainAppBarProps> {
+
+ render() {
+ const { classes, searchText, breadcrumbs, searchDebounce } = this.props
+ return <AppBar className={classes.appBar} position="static">
+ <Toolbar>
+ <Grid
+ container
+ justify="space-between"
+ >
+ <Grid item xs={3}>
+ <Typography variant="title" color="inherit" noWrap style={{ flexGrow: 1 }}>
+ <span>Arvados</span><br /><span style={{ fontSize: 12 }}>Workbench 2</span>
+ </Typography>
+ </Grid>
+ <Grid item xs={6} container alignItems="center">
+ {
+ this.props.user && <SearchBar
+ value={searchText}
+ onSearch={this.props.onSearch}
+ debounce={searchDebounce}
+ />
+ }
+ </Grid>
+ <Grid item xs={3} container alignItems="center" justify="flex-end">
+ {
+ this.props.user ? this.renderMenuForUser() : this.renderMenuForAnonymous()
+ }
+ </Grid>
+ </Grid>
+ </Toolbar>
+ {
+ this.props.user && <Toolbar>
+ <Breadcrumbs items={breadcrumbs} onClick={this.props.onBreadcrumbClick} />
+ </Toolbar>
+ }
+ </AppBar>
+ }
+
+ renderMenuForUser = () => {
+ const { user } = this.props
+ return (
+ <>
+ <IconButton color="inherit">
+ <Badge badgeContent={3} color="primary">
+ <NotificationsIcon />
+ </Badge>
+ </IconButton>
+ <DropdownMenu icon={PersonIcon} id="account-menu">
+ <MenuItem>{this.getUserFullname()}</MenuItem>
+ {this.renderMenuItems(this.props.menuItems.accountMenu)}
+ </DropdownMenu>
+ <DropdownMenu icon={HelpIcon} id="help-menu">
+ {this.renderMenuItems(this.props.menuItems.helpMenu)}
+ </DropdownMenu>
+ </>
+ )
+ }
+
+ renderMenuForAnonymous = () => {
+ return this.props.menuItems.anonymousMenu.map((item, index) => (
+ <Button color="inherit" onClick={() => this.props.onMenuItemClick(item)}>{item.label}</Button>
+ ))
+ }
+
+ renderMenuItems = (menuItems: MainAppBarMenuItem[]) => {
+ return menuItems.map((item, index) => (
+ <MenuItem key={index} onClick={() => this.props.onMenuItemClick(item)}>{item.label}</MenuItem>
+ ))
+ }
+
+ getUserFullname = () => {
+ const { user } = this.props;
+ return user ? `${user.firstName} ${user.lastName}` : "";
+ }
+
+}
+
+type CssRules = "appBar"
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+ appBar: {
+ backgroundColor: "#692498"
+ }
+})
+
+export default withStyles(styles)(MainAppBar)
\ 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 SearchBar, { DEFAULT_SEARCH_DEBOUNCE } from "./search-bar";
+
+import * as Adapter from 'enzyme-adapter-react-16';
+
+configure({ adapter: new Adapter() });
+
+describe("<SearchBar />", () => {
+
+ jest.useFakeTimers();
+
+ let onSearch: () => void;
+
+ beforeEach(() => {
+ onSearch = jest.fn();
+ });
+
+ describe("on submit", () => {
+ it("calls onSearch with initial value passed via props", () => {
+ const searchBar = mount(<SearchBar value="initial value" onSearch={onSearch} />);
+ searchBar.find("form").simulate("submit");
+ expect(onSearch).toBeCalledWith("initial value");
+ });
+
+ it("calls onSearch with current value", () => {
+ const searchBar = mount(<SearchBar value="" onSearch={onSearch} />);
+ searchBar.find("input").simulate("change", { target: { value: "current value" } });
+ searchBar.find("form").simulate("submit");
+ expect(onSearch).toBeCalledWith("current value");
+ });
+
+ it("calls onSearch with new value passed via props", () => {
+ const searchBar = mount(<SearchBar value="" onSearch={onSearch} />);
+ searchBar.find("input").simulate("change", { target: { value: "current value" } });
+ searchBar.setProps({value: "new value"});
+ searchBar.find("form").simulate("submit");
+ expect(onSearch).toBeCalledWith("new value");
+ });
+
+ it("cancels timeout set on input value change", () => {
+ const searchBar = mount(<SearchBar value="" onSearch={onSearch} debounce={1000} />);
+ searchBar.find("input").simulate("change", { target: { value: "current value" } });
+ searchBar.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 searchBar = mount(<SearchBar value="" onSearch={onSearch} />);
+ searchBar.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 searchBar = mount(<SearchBar value="" onSearch={onSearch} debounce={2000}/>);
+ searchBar.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 searchBar = mount(<SearchBar value="" onSearch={onSearch} debounce={1000}/>);
+ searchBar.find("input").simulate("change", { target: { value: "current value" } });
+ jest.advanceTimersByTime(500);
+ searchBar.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 searchBar = mount(<SearchBar value="" onSearch={onSearch} debounce={1000}/>);
+ searchBar.find("input").simulate("change", { target: { value: "current value" } });
+ jest.advanceTimersByTime(500);
+ searchBar.find("input").simulate("change", { target: { value: "intermediate value" } });
+ jest.advanceTimersByTime(1000);
+ expect(onSearch).toBeCalledWith("intermediate value")
+ searchBar.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 } from '@material-ui/core';
+import SearchIcon from '@material-ui/icons/Search';
+
+interface SearchBarDataProps {
+ value: string;
+}
+
+interface SearchBarActionProps {
+ onSearch: (value: string) => any;
+ debounce?: number;
+}
+
+type SearchBarProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>
+
+interface SearchBarState {
+ value: string;
+}
+
+export const DEFAULT_SEARCH_DEBOUNCE = 1000;
+
+class SearchBar extends React.Component<SearchBarProps> {
+
+ state: SearchBarState = {
+ value: ""
+ }
+
+ timeout: NodeJS.Timer;
+
+ render() {
+ const { classes } = this.props
+ return <Paper className={classes.container}>
+ <form onSubmit={this.handleSubmit}>
+ <input
+ className={classes.input}
+ onChange={this.handleChange}
+ placeholder="Search"
+ value={this.state.value}
+ />
+ <IconButton className={classes.button}>
+ <SearchIcon />
+ </IconButton>
+ </form>
+ </Paper>
+ }
+
+ componentDidMount() {
+ this.setState({value: this.props.value});
+ }
+
+ componentWillReceiveProps(nextProps: SearchBarProps) {
+ if (nextProps.value !== this.props.value) {
+ this.setState({ value: nextProps.value });
+ }
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.timeout);
+ }
+
+ handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+ 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 = setTimeout(
+ () => this.props.onSearch(this.state.value),
+ this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
+ );
+
+ }
+
+}
+
+type CssRules = 'container' | 'input' | 'button'
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+ const { unit } = theme.spacing
+ return {
+ container: {
+ position: 'relative',
+ width: '100%'
+ },
+ input: {
+ border: 'none',
+ borderRadius: unit / 4,
+ boxSizing: 'border-box',
+ padding: unit,
+ paddingRight: unit * 4,
+ width: '100%',
+ },
+ button: {
+ position: 'absolute',
+ top: unit / 2,
+ right: unit / 2,
+ width: unit * 3,
+ height: unit * 3
+ }
+ }
+}
+
+export default withStyles(styles)(SearchBar)
\ No newline at end of file
import { User } from "../../models/user";
import Grid from "@material-ui/core/Grid/Grid";
import { RootState } from "../../store/store";
+import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItems, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
+import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs';
+import { push } from 'react-router-redux';
import projectActions from "../../store/project/project-action"
-
import ProjectTree from '../../components/project-tree/project-tree';
import { TreeItem } from "../../components/tree/tree";
import { Project } from "../../models/project";
},
appBar: {
zIndex: theme.zIndex.drawer + 1,
- backgroundColor: '#692498'
+ backgroundColor: '#692498',
+ position: "absolute",
+ width: "100%"
},
drawerPaper: {
position: 'relative',
type WorkbenchProps = WorkbenchDataProps & WorkbenchActionProps & DispatchProp & WithStyles<CssRules>;
+interface NavBreadcrumb extends Breadcrumb {
+ path: string
+}
+
+interface NavMenuItem extends MainAppBarMenuItem {
+ action: () => void
+}
+
interface WorkbenchState {
anchorEl: any;
+ breadcrumbs: NavBreadcrumb[];
+ searchText: string;
+ menuItems: {
+ accountMenu: NavMenuItem[],
+ helpMenu: NavMenuItem[],
+ anonymousMenu: NavMenuItem[]
+ };
}
class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
- constructor(props: WorkbenchProps) {
- super(props);
- this.state = {
- anchorEl: null
+ state = {
+ anchorEl: null,
+ searchText: "",
+ breadcrumbs: [
+ {
+ label: "Projects",
+ path: "/projects"
+ }, {
+ label: "Project 1",
+ path: "/projects/project-1"
+ }
+ ],
+ menuItems: {
+ accountMenu: [
+ {
+ label: "Logout",
+ action: () => this.props.dispatch(authActions.LOGOUT())
+ },
+ {
+ label: "My account",
+ action: () => this.props.dispatch(push("/my-account"))
+ }
+ ],
+ helpMenu: [
+ {
+ label: "Help",
+ action: () => this.props.dispatch(push("/help"))
+ }
+ ],
+ anonymousMenu: [
+ {
+ label: "Sign in",
+ action: () => this.props.dispatch(authActions.LOGIN())
+ }
+ ]
}
}
- login = () => {
- this.props.dispatch(authActions.LOGIN());
- };
-
- logout = () => {
- this.handleClose();
- this.props.dispatch(authActions.LOGOUT());
- };
-
- handleOpenMenu = (event: React.MouseEvent<any>) => {
- this.setState({
- anchorEl: event.currentTarget
- });
- };
- handleClose = () => {
- this.setState({
- anchorEl: null
- });
- };
+ mainAppBarActions: MainAppBarActionProps = {
+ onBreadcrumbClick: (breadcrumb: NavBreadcrumb) => this.props.dispatch(push(breadcrumb.path)),
+ onSearch: searchText => {
+ this.setState({ searchText });
+ this.props.dispatch(push(`/search?q=${searchText}`));
+ },
+ onMenuItemClick: (menuItem: NavMenuItem) => menuItem.action()
+ }
toggleProjectTreeItem = (itemId: string) => {
this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => {
};
render() {
- const {classes, user} = this.props;
+ const { classes, user } = this.props;
return (
<div className={classes.root}>
- <AppBar position="absolute" className={classes.appBar}>
- <Toolbar>
- <Typography variant="title" color="inherit" noWrap style={{flexGrow: 1}}>
- <span>Arvados</span><br/><span style={{fontSize: 12}}>Workbench 2</span>
- </Typography>
- {user ?
- <Grid container style={{width: 'auto'}}>
- <Grid container style={{width: 'auto'}} alignItems='center'>
- <Typography variant="title" color="inherit" noWrap>
- {user.firstName} {user.lastName}
- </Typography>
- </Grid>
- <Grid item>
- <IconButton
- aria-owns={this.state.anchorEl ? 'menu-appbar' : undefined}
- aria-haspopup="true"
- onClick={this.handleOpenMenu}
- color="inherit">
- <AccountCircle/>
- </IconButton>
- </Grid>
- <Menu
- id="menu-appbar"
- anchorEl={this.state.anchorEl}
- anchorOrigin={{
- vertical: 'top',
- horizontal: 'right',
- }}
- transformOrigin={{
- vertical: 'top',
- horizontal: 'right',
- }}
- open={!!this.state.anchorEl}
- onClose={this.handleClose}>
- <MenuItem onClick={this.logout}>Logout</MenuItem>
- <MenuItem onClick={this.handleClose}>My account</MenuItem>
- </Menu>
- </Grid>
- :
- <Button color="inherit" onClick={this.login}>Login</Button>
- }
- </Toolbar>
- </AppBar>
+ <div className={classes.appBar}>
+ <MainAppBar
+ breadcrumbs={this.state.breadcrumbs}
+ searchText={this.state.searchText}
+ user={this.props.user}
+ menuItems={this.state.menuItems}
+ {...this.mainAppBarActions}
+ />
+ </div>
{user &&
<Drawer
variant="permanent"
toggleProjectTreeItem={this.toggleProjectTreeItem}/>
</Drawer>}
<main className={classes.content}>
- <div className={classes.toolbar}/>
+ <div className={classes.toolbar} />
+ <div className={classes.toolbar} />
<Switch>
- <Route path="/project/:name" component={ProjectList}/>
+ <Route path="/project/:name" component={ProjectList} />
</Switch>
</main>
</div>