const items = [
{ label: 'breadcrumb 1' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
expect(breadcrumbs.find(Button)).toHaveLength(1);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
});
{ label: 'breadcrumb 1' },
{ label: 'breadcrumb 2' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
expect(breadcrumbs.find(Button)).toHaveLength(2);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
});
{ label: 'breadcrumb 1' },
{ label: 'breadcrumb 2' }
];
- const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+ const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
breadcrumbs.find(Button).at(1).simulate('click');
expect(onClick).toBeCalledWith(items[1]);
});
interface BreadcrumbsProps {
items: Breadcrumb[];
onClick: (breadcrumb: Breadcrumb) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
}
-const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, items }) => {
+const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, onContextMenu, items }) => {
return <Grid container alignItems="center" wrap="nowrap">
{
items.map((item, index) => {
color="inherit"
className={isLastItem ? classes.currentItem : classes.item}
onClick={() => onClick(item)}
- >
+ onContextMenu={event => onContextMenu(event, item)}>
<Typography
noWrap
color="inherit"
- className={classes.label}
- >
+ className={classes.label}>
{item.label}
</Typography>
</Button>
export interface ContextMenuAction {
name: string;
icon: string;
+ openCreateDialog?: boolean;
}
export type ContextMenuActionGroup = ContextMenuAction[];
const onContextAction = jest.fn();
const dataExplorer = mount(<DataExplorer
{...mockDataExplorerProps()}
- contextActions={[]}
- onContextAction={onContextAction}
items={[{ key: "1", name: "item 1" }] as MockItem[]}
columns={[{ name: "Column 1", render: jest.fn(), selected: true }]} />);
expect(dataExplorer.find(ContextMenu).prop("actions")).toEqual([]);
{...mockDataExplorerProps()}
columns={columns}
onColumnToggle={onColumnToggle}
- contextActions={[]}
items={[{ key: "1", name: "item 1" }] as MockItem[]} />);
expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
onSortToggle: jest.fn(),
onRowClick: jest.fn(),
onColumnToggle: jest.fn(),
- onContextAction: jest.fn(),
onChangePage: jest.fn(),
- onChangeRowsPerPage: jest.fn()
+ onChangeRowsPerPage: jest.fn(),
+ onContextMenu: jest.fn()
});
\ No newline at end of file
import * as React from 'react';
import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, Theme, WithStyles, TablePagination, 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, { DataColumns, DataItem } from "../../components/data-table/data-table";
-import { mockAnchorFromMouseEvent } from "../../components/popover/helpers";
import { DataColumn } from "../../components/data-table/data-column";
import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
import SearchInput from '../search-input/search-input';
items: T[];
itemsAvailable: number;
columns: DataColumns<T>;
- contextActions: ContextMenuActionGroup[];
searchValue: string;
rowsPerPage: number;
rowsPerPageOptions?: number[];
onRowClick: (item: T) => void;
onRowDoubleClick: (item: T) => void;
onColumnToggle: (column: DataColumn<T>) => void;
- onContextAction: (action: ContextMenuAction, item: T) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, 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 DataItem> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>, DataExplorerState<T>> {
- state: DataExplorerState<T> = {
- contextMenu: {}
- };
+class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>> {
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}>
<Grid container justify="space-between" wrap="nowrap" alignItems="center">
<div className={this.props.classes.searchBox}>
columns={[...this.props.columns, this.contextMenuColumn]}
items={this.props.items}
onRowClick={(_, item: T) => this.props.onRowClick(item)}
+ onContextMenu={this.props.onContextMenu}
onRowDoubleClick={(_, item: T) => this.props.onRowDoubleClick(item)}
- onRowContextMenu={this.openContextMenu}
onFiltersChange={this.props.onFiltersChange}
onSortToggle={this.props.onSortToggle} />
<Toolbar>
</Paper>;
}
- openContextMenu = (event: React.MouseEvent<HTMLElement>, item: T) => {
- event.preventDefault();
- event.stopPropagation();
- 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);
}
renderContextMenuTrigger = (item: T) =>
<Grid container justify="flex-end">
- <IconButton onClick={event => this.openContextMenuTrigger(event, item)}>
+ <IconButton onClick={event => this.props.onContextMenu(event, item)}>
<MoreVertIcon />
</IconButton>
</Grid>
- openContextMenuTrigger = (event: React.MouseEvent<HTMLElement>, item: T) => {
- event.preventDefault();
- this.setState({
- contextMenu: {
- anchorEl: event.currentTarget,
- item
- }
- });
- }
-
contextMenuColumn = {
name: "Actions",
selected: true,
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={jest.fn()}
onSortToggle={jest.fn()} />);
expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
});
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={jest.fn()}
onSortToggle={jest.fn()} />);
expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
});
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={jest.fn()}
onSortToggle={jest.fn()} />);
expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
});
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={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");
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={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");
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={jest.fn()}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
+ onContextMenu={jest.fn()}
onSortToggle={onSortToggle} />);
expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
dataTable.find(TableSortLabel).at(0).simulate("click");
items={[{ key: "1", name: "item 1" }] as MockItem[]}
onFiltersChange={onFiltersChange}
onRowClick={jest.fn()}
- onRowContextMenu={jest.fn()}
- onSortToggle={jest.fn()} />);
+ onSortToggle={jest.fn()}
+ onContextMenu={jest.fn()} />);
expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters);
dataTable.find(DataTableFilters).prop("onChange")([]);
expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
items: T[];
columns: DataColumns<T>;
onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
onRowDoubleClick: (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 DataItem> extends React.Component<DataTableProps<T> & WithStyles<CssRules>> {
render() {
const { items, classes } = this.props;
- return <div className={classes.tableContainer}>
+ return <div
+ className={classes.tableContainer}>
<Table>
<TableHead>
<TableRow>
renderHeadCell = (column: DataColumn<T>, index: number) => {
const { name, key, renderHeader, filters, sortDirection } = column;
const { onSortToggle, onFiltersChange } = this.props;
- return <TableCell key={key || index} style={{width: column.width, minWidth: column.width}}>
+ return <TableCell key={key || index} style={{ width: column.width, minWidth: column.width }}>
{renderHeader ?
renderHeader() :
filters
}
renderBodyRow = (item: T, index: number) => {
- const { onRowClick, onRowDoubleClick, onRowContextMenu } = this.props;
+ const { onRowClick, onRowDoubleClick, onContextMenu } = this.props;
return <TableRow
hover
key={item.key}
onClick={event => onRowClick && onRowClick(event, item)}
- onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item) }
- onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}>
+ onContextMenu={this.handleRowContextMenu(item)}
+ onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item) }>
{this.mapVisibleColumns((column, index) => (
<TableCell key={column.key || index}>
{column.render(item)}
return this.props.columns.filter(column => column.selected).map(fn);
}
+ handleRowContextMenu = (item: T) =>
+ (event: React.MouseEvent<HTMLElement>) =>
+ this.props.onContextMenu(event, item)
+
}
type CssRules = "tableBody" | "tableContainer" | "noItemsInfo";
toggleOpen: (id: string) => void;
toggleActive: (id: string) => void;
sidePanelItems: SidePanelItem[];
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: SidePanelItem) => void;
}
class SidePanel extends React.Component<SidePanelProps & WithStyles<CssRules>> {
<List>
{sidePanelItems.map(it => (
<span key={it.name}>
- <ListItem button className={list} onClick={() => toggleActive(it.id)}>
+ <ListItem button className={list} onClick={() => toggleActive(it.id)} onContextMenu={this.handleRowContextMenu(it)}>
<span className={row}>
{it.openAble ? <i onClick={() => toggleOpen(it.id)} className={`${it.active ? activeArrow : inactiveArrow}
${it.open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} /> : null}
</div>
);
}
+
+ handleRowContextMenu = (item: SidePanelItem) =>
+ (event: React.MouseEvent<HTMLElement>) =>
+ item.openAble ? this.props.onContextMenu(event, item) : null
+
}
type CssRules = 'active' | 'listItemText' | 'row' | 'leftSidePanelContainer' | 'list' | 'icon' | 'projectIconMargin' |
toggleItemOpen: (id: string, status: TreeItemStatus) => void;
toggleItemActive: (id: string, status: TreeItemStatus) => void;
level?: number;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
}
class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
- renderArrow(status: TreeItemStatus, arrowClass: string, open: boolean, id: string) {
- const { arrowTransition, arrowVisibility, arrowRotate } = this.props.classes;
- return <i onClick={() => this.props.toggleItemOpen(id, status)}
- className={`
- ${arrowClass}
- ${status === TreeItemStatus.Pending ? arrowVisibility : ''}
- ${open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} />;
- }
render(): ReactElement<any> {
const level = this.props.level ? this.props.level : 0;
- const { classes, render, toggleItemOpen, items, toggleItemActive } = this.props;
+ const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu } = this.props;
const { list, inactiveArrow, activeArrow, loader } = classes;
return <List component="div" className={list}>
{items && items.map((it: TreeItem<T>, idx: number) =>
<div key={`item/${level}/${idx}`}>
- <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)}>
+ <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)} onContextMenu={this.handleRowContextMenu(it)}>
{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)}
render={render}
toggleItemOpen={toggleItemOpen}
toggleItemActive={toggleItemActive}
- level={level + 1} />
+ level={level + 1}
+ onContextMenu={onContextMenu} />
</Collapse>}
</div>)}
</List>;
}
+ renderArrow(status: TreeItemStatus, arrowClass: string, open: boolean, id: string) {
+ const { arrowTransition, arrowVisibility, arrowRotate } = this.props.classes;
+ return <i onClick={() => this.props.toggleItemOpen(id, status)}
+ className={`
+ ${arrowClass}
+ ${status === TreeItemStatus.Pending ? arrowVisibility : ''}
+ ${open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} />;
+ }
+
+ handleRowContextMenu = (item: TreeItem<T>) =>
+ (event: React.MouseEvent<HTMLElement>) =>
+ this.props.onContextMenu(event, item)
}
type CssRules = 'list' | 'activeArrow' | 'inactiveArrow' | 'arrowRotate' | 'arrowTransition' | 'loader' | 'arrowVisibility';
import { Dispatch } from "redux";
import { getResourceKind } from "../../models/resource";
import FilterBuilder from "../../common/api/filter-builder";
+import { ThunkAction } from "../../../node_modules/redux-thunk";
+import { RootState } from "../store";
const actions = unionize({
- CREATE_PROJECT: ofType<Project>(),
+ OPEN_PROJECT_CREATOR: ofType<{ ownerUuid: string }>(),
+ CLOSE_PROJECT_CREATOR: ofType<{}>(),
+ CREATE_PROJECT: ofType<Partial<ProjectResource>>(),
+ CREATE_PROJECT_SUCCESS: ofType<ProjectResource>(),
+ CREATE_PROJECT_ERROR: ofType<string>(),
REMOVE_PROJECT: ofType<string>(),
PROJECTS_REQUEST: ofType<string>(),
PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(),
});
};
+export const createProject = (project: Partial<ProjectResource>) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const { ownerUuid } = getState().projects.creator;
+ const projectData = { ownerUuid, ...project };
+ dispatch(actions.CREATE_PROJECT(projectData));
+ return projectService
+ .create(projectData)
+ .then(project => dispatch(actions.CREATE_PROJECT_SUCCESS(project)))
+ .catch(() => dispatch(actions.CREATE_PROJECT_ERROR("Could not create a project")));
+ };
+
export type ProjectAction = UnionOf<typeof actions>;
export default actions;
active: true,
status: 1
}],
- currentItemId: "1"
+ currentItemId: "1",
+ creator: { opened: false, pending: false, ownerUuid: "" },
};
const project = {
items: [{
active: false,
status: 1
}],
- currentItemId: "1"
+ currentItemId: "1",
+ creator: { opened: false, pending: false, ownerUuid: "" }
};
const project = {
items: [{
status: 1,
toggled: true
}],
- currentItemId: "1"
+ currentItemId: "1",
+ creator: { opened: false, pending: false, ownerUuid: "" },
};
const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState.items[0].id));
status: 1,
toggled: false,
}],
- currentItemId: "1"
+ currentItemId: "1",
+ creator: { opened: false, pending: false, ownerUuid: "" }
};
const project = {
items: [{
status: 1,
toggled: true
}],
- currentItemId: "1"
+ currentItemId: "1",
+ creator: { opened: false, pending: false, ownerUuid: "" },
};
const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState.items[0].id));
export type ProjectState = {
items: Array<TreeItem<Project>>,
- currentItemId: string
+ currentItemId: string,
+ creator: ProjectCreator
};
+interface ProjectCreator {
+ opened: boolean;
+ pending: boolean;
+ ownerUuid: string;
+}
+
export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
let item;
for (const t of tree) {
}
export function getTreePath<T>(tree: Array<TreeItem<T>>, itemId: string): Array<TreeItem<T>> {
- for (const item of tree){
- if(item.id === itemId){
+ for (const item of tree) {
+ if (item.id === itemId) {
return [item];
} else {
const branch = getTreePath(item.items || [], itemId);
- if(branch.length > 0){
+ if (branch.length > 0) {
return [item, ...branch];
}
}
return items;
}
-const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" }, action: ProjectAction) => {
+const updateCreator = (state: ProjectState, creator: Partial<ProjectCreator>) => ({
+ ...state,
+ creator: {
+ ...state.creator,
+ ...creator
+ }
+});
+
+const initialState: ProjectState = {
+ items: [],
+ currentItemId: "",
+ creator: {
+ opened: false,
+ pending: false,
+ ownerUuid: ""
+ }
+};
+
+
+const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => {
return actions.match(action, {
- CREATE_PROJECT: project => ({
- ...state,
- items: state.items.concat({
- id: project.uuid,
- open: false,
- active: false,
- status: TreeItemStatus.Loaded,
- toggled: false,
- items: [],
- data: project
- })
- }),
+ OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true, pending: false }),
+ CLOSE_PROJECT_CREATOR: () => updateCreator(state, { opened: false }),
+ CREATE_PROJECT: () => updateCreator(state, { opened: false, pending: true }),
+ CREATE_PROJECT_SUCCESS: () => updateCreator(state, { ownerUuid: "", pending: false }),
+ CREATE_PROJECT_ERROR: () => updateCreator(state, { ownerUuid: "", pending: false }),
REMOVE_PROJECT: () => state,
PROJECTS_REQUEST: itemId => {
const items = _.cloneDeep(state.items);
item.open = !item.open;
}
return {
+ ...state,
items,
currentItemId: itemId
};
item.active = true;
}
return {
+ ...state,
items,
currentItemId: itemId
};
const items = _.cloneDeep(state.items);
resetTreeActivity(items);
return {
+ ...state,
items,
currentItemId: ""
};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+
+type ValidatorProps = {
+ value: string,
+ onChange: (isValid: boolean | string) => void;
+ render: (hasError: boolean) => React.ReactElement<any>;
+ isRequired: boolean;
+};
+
+interface ValidatorState {
+ isPatternValid: boolean;
+ isLengthValid: boolean;
+}
+
+const nameRegEx = /^[a-zA-Z0-9-_ ]+$/;
+const maxInputLength = 60;
+
+class Validator extends React.Component<ValidatorProps & WithStyles<CssRules>> {
+ state: ValidatorState = {
+ isPatternValid: true,
+ isLengthValid: true
+ };
+
+ componentWillReceiveProps(nextProps: ValidatorProps) {
+ const { value } = nextProps;
+
+ if (this.props.value !== value) {
+ this.setState({
+ isPatternValid: value.match(nameRegEx),
+ isLengthValid: value.length < maxInputLength
+ }, () => this.onChange());
+ }
+ }
+
+ onChange() {
+ const { value, onChange, isRequired } = this.props;
+ const { isPatternValid, isLengthValid } = this.state;
+ const isValid = value && isPatternValid && isLengthValid && (isRequired || (!isRequired && value.length > 0));
+
+ onChange(isValid);
+ }
+
+ render() {
+ const { classes, isRequired, value } = this.props;
+ const { isPatternValid, isLengthValid } = this.state;
+
+ return (
+ <span>
+ {this.props.render(!(isPatternValid && isLengthValid) && (isRequired || (!isRequired && value.length > 0)))}
+ {!isPatternValid && (isRequired || (!isRequired && value.length > 0)) ? <span className={classes.formInputError}>This field allow only alphanumeric characters, dashes, spaces and underscores.<br /></span> : null}
+ {!isLengthValid ? <span className={classes.formInputError}>This field should have max 60 characters.</span> : null}
+ </span>
+ );
+ }
+}
+
+type CssRules = "formInputError";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+ formInputError: {
+ color: "#ff0000",
+ marginLeft: "5px",
+ fontSize: "11px",
+ }
+});
+
+export default withStyles(styles)(Validator);
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch } from "../../../node_modules/redux";
+import { RootState } from "../../store/store";
+import DialogProjectCreate from "../dialog-create/dialog-project-create";
+import actions, { createProject, getProjectList } from "../../store/project/project-action";
+import dataExplorerActions from "../../store/data-explorer/data-explorer-action";
+import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+
+const mapStateToProps = (state: RootState) => ({
+ open: state.projects.creator.opened
+});
+
+const submit = (data: { name: string, description: string }) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const { ownerUuid } = getState().projects.creator;
+ dispatch<any>(createProject(data)).then(() => {
+ dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+ dispatch<any>(getProjectList(ownerUuid));
+ });
+ };
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+ handleClose: () => {
+ dispatch(actions.CLOSE_PROJECT_CREATOR());
+ },
+ onSubmit: (data: { name: string, description: string }) => {
+ dispatch<any>(submit(data));
+ }
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DialogProjectCreate);
interface Props {
id: string;
- contextActions: ContextMenuActionGroup[];
onRowClick: (item: any) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
onRowDoubleClick: (item: any) => void;
- onContextAction: (action: ContextMenuAction, item: any) => void;
}
-const mapStateToProps = (state: RootState, { id, contextActions }: Props) =>
+const mapStateToProps = (state: RootState, { id }: Props) =>
getDataExplorer(state.dataExplorer, id);
-const mapDispatchToProps = (dispatch: Dispatch, { id, contextActions, onRowClick, onRowDoubleClick, onContextAction }: Props) => ({
+const mapDispatchToProps = (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
onSearch: (searchValue: string) => {
dispatch(actions.SET_SEARCH_VALUE({ id, searchValue }));
},
dispatch(actions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
},
- contextActions,
-
onRowClick,
onRowDoubleClick,
-
- onContextAction
+
+ onContextMenu,
});
export default connect(mapStateToProps, mapDispatchToProps)(DataExplorer);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import TextField from '@material-ui/core/TextField';
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+
+import Validator from '../../utils/dialog-validator';
+
+interface ProjectCreateProps {
+ open: boolean;
+ handleClose: () => void;
+ onSubmit: (data: { name: string, description: string }) => void;
+}
+
+interface DialogState {
+ name: string;
+ description: string;
+ isNameValid: boolean;
+ isDescriptionValid: boolean;
+}
+
+class DialogProjectCreate extends React.Component<ProjectCreateProps & WithStyles<CssRules>> {
+ state: DialogState = {
+ name: '',
+ description: '',
+ isNameValid: false,
+ isDescriptionValid: true
+ };
+
+ render() {
+ const { name, description } = this.state;
+ const { classes, open, handleClose } = this.props;
+
+ return (
+ <Dialog
+ open={open}
+ onClose={handleClose}>
+ <div className={classes.dialog}>
+ <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a project</DialogTitle>
+ <DialogContent className={classes.dialogContent}>
+ <Validator
+ value={name}
+ onChange={e => this.isNameValid(e)}
+ isRequired={true}
+ render={hasError =>
+ <TextField
+ margin="dense"
+ className={classes.textField}
+ id="name"
+ onChange={e => this.handleProjectName(e)}
+ label="Project name"
+ error={hasError}
+ fullWidth />} />
+ <Validator
+ value={description}
+ onChange={e => this.isDescriptionValid(e)}
+ isRequired={false}
+ render={hasError =>
+ <TextField
+ margin="dense"
+ className={classes.textField}
+ id="description"
+ onChange={e => this.handleDescriptionValue(e)}
+ label="Description - optional"
+ error={hasError}
+ fullWidth />} />
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={handleClose} className={classes.button} color="primary">CANCEL</Button>
+ <Button onClick={this.handleSubmit} className={classes.lastButton} color="primary" disabled={!this.state.isNameValid || (!this.state.isDescriptionValid && description.length > 0)} variant="raised">CREATE A PROJECT</Button>
+ </DialogActions>
+ </div>
+ </Dialog>
+ );
+ }
+
+ handleSubmit = () => {
+ this.props.onSubmit({
+ name: this.state.name,
+ description: this.state.description
+ });
+ }
+
+ handleProjectName(e: any) {
+ this.setState({
+ name: e.target.value,
+ });
+ }
+
+ handleDescriptionValue(e: any) {
+ this.setState({
+ description: e.target.value,
+ });
+ }
+
+ isNameValid(value: boolean | string) {
+ this.setState({
+ isNameValid: value,
+ });
+ }
+
+ isDescriptionValid(value: boolean | string) {
+ this.setState({
+ isDescriptionValid: value,
+ });
+ }
+}
+
+type CssRules = "button" | "lastButton" | "dialogContent" | "textField" | "dialog" | "dialogTitle";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+ button: {
+ marginLeft: theme.spacing.unit
+ },
+ lastButton: {
+ marginLeft: theme.spacing.unit,
+ marginRight: "20px",
+ },
+ dialogContent: {
+ marginTop: "20px",
+ },
+ dialogTitle: {
+ paddingBottom: "0"
+ },
+ textField: {
+ marginTop: "32px",
+ },
+ dialog: {
+ minWidth: "600px",
+ minHeight: "320px"
+ }
+});
+
+export default withStyles(styles)(DialogProjectCreate);
\ No newline at end of file
onSearch: (searchText: string) => void;
onBreadcrumbClick: (breadcrumb: Breadcrumb) => void;
onMenuItemClick: (menuItem: MainAppBarMenuItem) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
onDetailsPanelToggle: () => void;
}
</Toolbar>
<Toolbar >
{
- props.user && <Breadcrumbs items={props.breadcrumbs} onClick={props.onBreadcrumbClick} />
+ props.user && <Breadcrumbs
+ items={props.breadcrumbs}
+ onClick={props.onBreadcrumbClick}
+ onContextMenu={props.onContextMenu} />
}
<IconButton color="inherit" onClick={props.onDetailsPanelToggle}>
<InfoIcon />
projects: Array<TreeItem<Project>>;
toggleOpen: (id: string, status: TreeItemStatus) => void;
toggleActive: (id: string, status: TreeItemStatus) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<Project>) => void;
}
class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
render(): ReactElement<any> {
- const { classes, projects, toggleOpen, toggleActive } = this.props;
+ const { classes, projects, toggleOpen, toggleActive, onContextMenu } = this.props;
const { active, listItemText, row, treeContainer } = classes;
return (
<div className={treeContainer}>
<Tree items={projects}
+ onContextMenu={onContextMenu}
toggleItemOpen={toggleOpen}
toggleItemActive={toggleActive}
render={(project: TreeItem<Project>) =>
import * as React from 'react';
import { ProjectPanelItem } from './project-panel-item';
-import { Grid, Typography, Button, Toolbar, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { Grid, Typography, Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
import { formatDate, formatFileSize } from '../../common/formatters';
import DataExplorer from "../../views-components/data-explorer/data-explorer";
-import { ContextMenuAction } from '../../components/context-menu/context-menu';
import { DispatchProp, connect } from 'react-redux';
import { DataColumns } from '../../components/data-table/data-table';
import { RouteComponentProps } from 'react-router';
type ProjectPanelProps = {
currentItemId: string,
onItemClick: (item: ProjectPanelItem) => void,
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, item: ProjectPanelItem) => void;
+ onDialogOpen: (ownerUuid: string) => void;
onItemDoubleClick: (item: ProjectPanelItem) => void,
onItemRouteChange: (itemId: string) => void
}
& DispatchProp
& WithStyles<CssRules>
& RouteComponentProps<{ id: string }>;
-class ProjectPanel extends React.Component<ProjectPanelProps> {
+
+class ProjectPanel extends React.Component<ProjectPanelProps> {
render() {
return <div>
<div className={this.props.classes.toolbar}>
<Button color="primary" variant="raised" className={this.props.classes.button}>
Run a process
</Button>
- <Button color="primary" variant="raised" className={this.props.classes.button}>
- Create a project
+ <Button color="primary" onClick={() => this.props.onDialogOpen(this.props.currentItemId)} variant="raised" className={this.props.classes.button}>
+ New project
</Button>
</div>
<DataExplorer
id={PROJECT_PANEL_ID}
- contextActions={contextMenuActions}
onRowClick={this.props.onItemClick}
onRowDoubleClick={this.props.onItemDoubleClick}
- onContextAction={this.executeAction} />;
+ onContextMenu={this.props.onContextMenu} />
</div>;
}
this.props.onItemRouteChange(match.params.id);
}
}
-
- executeAction = (action: ContextMenuAction, item: ProjectPanelItem) => {
- alert(`Executing ${action.name} on ${item.name}`);
- }
-
}
type CssRules = "toolbar" | "button";
},
button: {
marginLeft: theme.spacing.unit
- }
+ },
});
const renderName = (item: ProjectPanelItem) =>
width: "150px"
}];
-const 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"
-}
-]];
export default withStyles(styles)(
connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
import ProjectPanel from "../project-panel/project-panel";
import DetailsPanel from '../../views-components/details-panel/details-panel';
import { ArvadosTheme } from '../../common/custom-theme';
+import ContextMenu, { ContextMenuAction } from '../../components/context-menu/context-menu';
+import { mockAnchorFromMouseEvent } from '../../components/popover/helpers';
+import CreateProjectDialog from "../../views-components/create-project-dialog/create-project-dialog";
+import { authService } from '../../services/services';
+
import detailsPanelActions, { loadDetails } from "../../store/details-panel/details-panel-action";
import { ResourceKind } from '../../models/kinds';
}
interface WorkbenchState {
+ contextMenu: {
+ anchorEl?: HTMLElement;
+ itemUuid?: string;
+ };
anchorEl: any;
searchText: string;
menuItems: {
class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
state = {
+ contextMenu: {
+ anchorEl: undefined,
+ itemUuid: undefined
+ },
+ isCreationDialogOpen: false,
anchorEl: null,
searchText: "",
breadcrumbs: [],
onMenuItemClick: (menuItem: NavMenuItem) => menuItem.action(),
onDetailsPanelToggle: () => {
this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+ },
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: NavBreadcrumb) => {
+ this.openContextMenu(event, breadcrumb.itemId);
}
};
this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
}
+ handleCreationDialogOpen = (itemUuid: string) => {
+ this.closeContextMenu();
+ this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
+ }
+
+
+ openContextMenu = (event: React.MouseEvent<HTMLElement>, itemUuid: string) => {
+ event.preventDefault();
+ this.setState({
+ contextMenu: {
+ anchorEl: mockAnchorFromMouseEvent(event),
+ itemUuid
+ }
+ });
+ }
+
+ closeContextMenu = () => {
+ this.setState({ contextMenu: {} });
+ }
+
+ openCreateDialog = (item: ContextMenuAction) => {
+ const { itemUuid } = this.state.contextMenu;
+ if (item.openCreateDialog && itemUuid) {
+ this.handleCreationDialogOpen(itemUuid);
+ }
+ }
+
render() {
const path = getTreePath(this.props.projects, this.props.currentProjectId);
const breadcrumbs = path.map(item => ({
searchText={this.state.searchText}
user={this.props.user}
menuItems={this.state.menuItems}
- {...this.mainAppBarActions}
- />
+ {...this.mainAppBarActions} />
</div>
{user &&
<Drawer
<SidePanel
toggleOpen={this.toggleSidePanelOpen}
toggleActive={this.toggleSidePanelActive}
- sidePanelItems={this.props.sidePanelItems}>
+ sidePanelItems={this.props.sidePanelItems}
+ onContextMenu={(event) => this.openContextMenu(event, authService.getUuid() || "")}>
<ProjectTree
projects={this.props.projects}
toggleOpen={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.OPEN))}
+ onContextMenu={(event, item) => this.openContextMenu(event, item.data.uuid)}
toggleActive={itemId => {
this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE));
this.props.dispatch<any>(loadDetails(itemId, ResourceKind.Project));
- }}
- />
+ }}/>
</SidePanel>
</Drawer>}
<main className={classes.contentWrapper}>
</div>
<DetailsPanel />
</main>
+ <ContextMenu
+ anchorEl={this.state.contextMenu.anchorEl}
+ actions={contextMenuActions}
+ onActionClick={this.openCreateDialog}
+ onClose={this.closeContextMenu} />
+ <CreateProjectDialog />
</div>
);
}
renderProjectPanel = (props: RouteComponentProps<{ id: string }>) => <ProjectPanel
onItemRouteChange={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE))}
+ onContextMenu={(event, item) => this.openContextMenu(event, item.uuid)}
+ onDialogOpen={this.handleCreationDialogOpen}
onItemClick={item => {
this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
}}
this.props.dispatch<any>(loadDetails(item.uuid, ResourceKind.Project));
}}
{...props} />
+}
+const contextMenuActions = [[{
+ icon: "fas fa-plus fa-fw",
+ name: "New project",
+ openCreateDialog: true
+}, {
+ 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"
}
+]];
export default connect<WorkbenchDataProps>(
(state: RootState) => ({