import * as React from 'react';
import Typography from '@material-ui/core/Typography';
import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { ArvadosTheme } from 'src/common/custom-theme';
+import { ArvadosTheme } from '../../common/custom-theme';
interface AttributeDataProps {
label: string;
import * as Adapter from "enzyme-adapter-react-16";
import ContextMenu from "./context-menu";
import { ListItem } from "@material-ui/core";
+import { IconTypes } from "../icon/icon";
configure({ adapter: new Adapter() });
describe("<ContextMenu />", () => {
- const actions = [[{
- icon: "",
+ const items = [[{
+ icon: IconTypes.ANNOUNCEMENT,
name: "Action 1.1"
}, {
- icon: "",
+ icon: IconTypes.ANNOUNCEMENT,
name: "Action 1.2"
},], [{
- icon: "",
+ icon: IconTypes.ANNOUNCEMENT,
name: "Action 2.1"
}]];
- it("calls onActionClick with clicked action", () => {
- const onActionClick = jest.fn();
+ it("calls onItemClick with clicked action", () => {
+ const onItemClick = jest.fn();
const contextMenu = mount(<ContextMenu
anchorEl={document.createElement("div")}
onClose={jest.fn()}
- onActionClick={onActionClick}
- actions={actions} />);
+ onItemClick={onItemClick}
+ items={items} />);
contextMenu.find(ListItem).at(2).simulate("click");
- expect(onActionClick).toHaveBeenCalledWith(actions[1][0]);
+ expect(onItemClick).toHaveBeenCalledWith(items[1][0]);
});
});
\ No newline at end of file
import * as React from "react";
import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
import { DefaultTransformOrigin } from "../popover/helpers";
+import IconBase, { IconTypes } from "../icon/icon";
-export interface ContextMenuAction {
+export interface ContextMenuItem {
name: string;
- icon: string;
- openCreateDialog?: boolean;
+ icon: IconTypes;
}
-export type ContextMenuActionGroup = ContextMenuAction[];
+export type ContextMenuItemGroup = ContextMenuItem[];
-export interface ContextMenuProps<T> {
+export interface ContextMenuProps {
anchorEl?: HTMLElement;
- actions: ContextMenuActionGroup[];
- onActionClick: (action: ContextMenuAction) => void;
+ items: ContextMenuItemGroup[];
+ onItemClick: (action: ContextMenuItem) => void;
onClose: () => void;
}
-export default class ContextMenu<T> extends React.PureComponent<ContextMenuProps<T>> {
+export default class ContextMenu extends React.PureComponent<ContextMenuProps> {
render() {
- const { anchorEl, actions, onClose, onActionClick } = this.props;
+ const { anchorEl, items, onClose, onItemClick } = this.props;
return <Popover
anchorEl={anchorEl}
open={!!anchorEl}
anchorOrigin={DefaultTransformOrigin}
onContextMenu={this.handleContextMenu}>
<List dense>
- {actions.map((group, groupIndex) =>
+ {items.map((group, groupIndex) =>
<React.Fragment key={groupIndex}>
- {group.map((action, actionIndex) =>
+ {group.map((item, actionIndex) =>
<ListItem
button
key={actionIndex}
- onClick={() => onActionClick(action)}>
+ onClick={() => onItemClick(item)}>
<ListItemIcon>
- <i className={action.icon} />
+ <IconBase icon={item.icon} />
</ListItemIcon>
<ListItemText>
- {action.name}
+ {item.name}
</ListItemText>
</ListItem>)}
- {groupIndex < actions.length - 1 && <Divider />}
+ {groupIndex < items.length - 1 && <Divider />}
</React.Fragment>)}
</List>
</Popover>;
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 ColumnSelector from "../../components/column-selector/column-selector";
-import DataTable, { DataColumns } from "../../components/data-table/data-table";
-import { DataColumn } from "../../components/data-table/data-column";
-import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
+import ColumnSelector from "../column-selector/column-selector";
+import DataTable, { DataColumns } from "../data-table/data-table";
+import { DataColumn } from "../data-table/data-column";
+import { DataTableFilterItem } from '../data-table-filters/data-table-filters';
import SearchInput from '../search-input/search-input';
interface DataExplorerProps<T> {
import * as React from 'react';
import Typography from '@material-ui/core/Typography';
import { WithStyles, withStyles, StyleRulesCallback } from '@material-ui/core/styles';
-import { ArvadosTheme } from 'src/common/custom-theme';
+import { ArvadosTheme } from '../../common/custom-theme';
import IconBase, { IconTypes } from '../icon/icon';
export interface EmptyStateDataProps {
import { PopoverOrigin } from "@material-ui/core/Popover";
-export const mockAnchorFromMouseEvent = (event: React.MouseEvent<HTMLElement>) => {
+export const createAnchorAt = (position: {x: number, y: number}) => {
const el = document.createElement('div');
const clientRect = {
- left: event.clientX,
- right: event.clientX,
- top: event.clientY,
- bottom: event.clientY,
+ left: position.x,
+ right: position.x,
+ top: position.y,
+ bottom: position.y,
width: 0,
height: 0
};
// SPDX-License-Identifier: AGPL-3.0
import axios from "axios";
-import MockAdapter from "axios-mock-adapter";
+import MockAdapter from "axios-mock-adapter/types";
import ProjectService from "./project-service";
import FilterBuilder from "../../common/api/filter-builder";
import { ProjectResource } from "../../models/project";
//
// SPDX-License-Identifier: AGPL-3.0
-// import { default as unionize, ofType, UnionOf } from "unionize";
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { ContextMenuPosition, ContextMenuResource } from "./context-menu-reducer";
-// const actions = unionize({
-// OPEN_CONTEXT_MENU: ofType<{position: {x: number, y: number}}>()
-// }, {
-// tag: 'type',
-// value: 'payload'
-// });
+const actions = unionize({
+ OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
+ CLOSE_CONTEXT_MENU: ofType<{}>()
+}, {
+ tag: 'type',
+ value: 'payload'
+ });
-// export type ContextMenuAction = UnionOf<typeof actions>;
-// export default actions;
\ No newline at end of file
+export type ContextMenuAction = UnionOf<typeof actions>;
+export default actions;
\ No newline at end of file
//
// SPDX-License-Identifier: AGPL-3.0
-// import actions, { DetailsPanelAction } from "./details-panel-action";
-// import { Resource, ResourceKind } from "../../models/resource";
+import { ResourceKind } from "../../models/resource";
+import actions, { ContextMenuAction } from "./context-menu-actions";
-// export interface ContextMenuState {
-// position: {
-// x: number;
-// y: number;
-// },
-// resource: {
-// uuid: string;
-// kind: ResourceKind.
-// }
-// }
+export interface ContextMenuState {
+ position: ContextMenuPosition;
+ resource?: ContextMenuResource;
+}
-// const initialState = {
-// item: null,
-// isOpened: false
-// };
+export interface ContextMenuPosition {
+ x: number;
+ y: number;
+}
-// const reducer = (state: DetailsPanelState = initialState, action: DetailsPanelAction) =>
-// actions.match(action, {
-// default: () => state,
-// LOAD_DETAILS: () => state,
-// LOAD_DETAILS_SUCCESS: ({ item }) => ({ ...state, item }),
-// TOGGLE_DETAILS_PANEL: () => ({ ...state, isOpened: !state.isOpened })
-// });
+export interface ContextMenuResource {
+ uuid: string;
+ kind: string;
+}
-// export default reducer;
+const initialState = {
+ position: { x: 0, y: 0 }
+};
+
+const reducer = (state: ContextMenuState = initialState, action: ContextMenuAction) =>
+ actions.match(action, {
+ default: () => state,
+ OPEN_CONTEXT_MENU: ({resource, position}) => ({ resource, position }),
+ CLOSE_CONTEXT_MENU: () => ({ position: state.position })
+ });
+
+export default reducer;
// SPDX-License-Identifier: AGPL-3.0
import { Middleware } from "redux";
-import actions from "../../store/data-explorer/data-explorer-action";
+import actions from "../data-explorer/data-explorer-action";
import { PROJECT_PANEL_ID, columns, ProjectPanelFilter, ProjectPanelColumnNames } from "../../views/project-panel/project-panel";
import { groupsService } from "../../services/services";
-import { RootState } from "../../store/store";
-import { getDataExplorer, DataExplorerState } from "../../store/data-explorer/data-explorer-reducer";
+import { RootState } from "../store";
+import { getDataExplorer, DataExplorerState } from "../data-explorer/data-explorer-reducer";
import { resourceToDataItem, ProjectPanelItem } from "../../views/project-panel/project-panel-item";
import FilterBuilder from "../../common/api/filter-builder";
import { DataColumns } from "../../components/data-table/data-table";
import sidePanelReducer, { SidePanelState } from './side-panel/side-panel-reducer';
import authReducer, { AuthState } from "./auth/auth-reducer";
import dataExplorerReducer, { DataExplorerState } from './data-explorer/data-explorer-reducer';
-import { projectPanelMiddleware } from '../store/project-panel/project-panel-middleware';
+import { projectPanelMiddleware } from './project-panel/project-panel-middleware';
import detailsPanelReducer, { DetailsPanelState } from './details-panel/details-panel-reducer';
+import contextMenuReducer, { ContextMenuState } from './context-menu/context-menu-reducer';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
dataExplorer: DataExplorerState;
sidePanel: SidePanelState;
detailsPanel: DetailsPanelState;
+ contextMenu: ContextMenuState;
}
const rootReducer = combineReducers({
router: routerReducer,
dataExplorer: dataExplorerReducer,
sidePanel: sidePanelReducer,
- detailsPanel: detailsPanelReducer
+ detailsPanel: detailsPanelReducer,
+ contextMenu: contextMenuReducer
});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+
+export const emptyActionSet: ContextMenuActionSet = [];
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import actions from "../../../store/project/project-action";
+import { IconTypes } from "../../../components/icon/icon";
+
+export const projectActionSet: ContextMenuActionSet = [[{
+ icon: IconTypes.FOLDER,
+ name: "New project",
+ execute: (dispatch, resource) => {
+ dispatch(actions.OPEN_PROJECT_CREATOR({ ownerUuid: resource.uuid }));
+ }
+}, {
+ icon: IconTypes.ANNOUNCEMENT,
+ name: "Share",
+ execute: () => { return; }
+}]];
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import actions from "../../../store/project/project-action";
+import { IconTypes } from "../../../components/icon/icon";
+
+export const rootProjectActionSet: ContextMenuActionSet = [[{
+ icon: IconTypes.FOLDER,
+ name: "New project",
+ execute: (dispatch, resource) => {
+ dispatch(actions.OPEN_PROJECT_CREATOR({ ownerUuid: resource.uuid }));
+ }
+}]];
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { ContextMenuItem } from "../../components/context-menu/context-menu";
+import { ContextMenuResource } from "../../store/context-menu/context-menu-reducer";
+
+export interface ContextMenuAction extends ContextMenuItem {
+ execute(dispatch: Dispatch, resource: ContextMenuResource): void;
+}
+
+export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect, Dispatch, DispatchProp } from "react-redux";
+import { RootState } from "../../store/store";
+import actions from "../../store/context-menu/context-menu-actions";
+import ContextMenu, { ContextMenuProps, ContextMenuItem } from "../../components/context-menu/context-menu";
+import { createAnchorAt } from "../../components/popover/helpers";
+import { ContextMenuResource } from "../../store/context-menu/context-menu-reducer";
+import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
+import { emptyActionSet } from "./action-sets/empty-action-set";
+
+type DataProps = Pick<ContextMenuProps, "anchorEl" | "items"> & { resource?: ContextMenuResource };
+const mapStateToProps = (state: RootState): DataProps => {
+ const { position, resource } = state.contextMenu;
+ return {
+ anchorEl: resource ? createAnchorAt(position) : undefined,
+ items: getMenuActionSet(resource),
+ resource
+ };
+};
+
+type ActionProps = Pick<ContextMenuProps, "onClose"> & { onItemClick: (item: ContextMenuItem, resource?: ContextMenuResource) => void };
+const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({
+ onClose: () => {
+ dispatch(actions.CLOSE_CONTEXT_MENU());
+ },
+ onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => {
+ dispatch(actions.CLOSE_CONTEXT_MENU());
+ if (resource) {
+ action.execute(dispatch, resource);
+ }
+ }
+});
+
+const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({
+ ...dataProps,
+ ...actionProps,
+ onItemClick: item => {
+ actionProps.onItemClick(item, resource);
+ }
+});
+
+export const ContextMenuHOC = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenu);
+
+const menuActionSets = new Map<string, ContextMenuActionSet>();
+
+export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) => {
+ menuActionSets.set(name, itemSet);
+};
+
+const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => {
+ return resource ? menuActionSets.get(resource.kind) || emptyActionSet : emptyActionSet;
+};
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuHOC, addMenuActionSet } from "./context-menu";
+import { projectActionSet } from "./action-sets/project-action-set";
+import { rootProjectActionSet } from "./action-sets/root-project-action-set";
+
+export default ContextMenuHOC;
+
+export enum ContextMenuKind {
+ RootProject = "RootProject",
+ Project = "Project"
+}
+
+addMenuActionSet(ContextMenuKind.RootProject, rootProjectActionSet);
+addMenuActionSet(ContextMenuKind.Project, projectActionSet);
\ No newline at end of file
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 ContextMenu, { ContextMenuKind } from "../../views-components/context-menu";
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 contextMenuActions from "../../store/context-menu/context-menu-actions";
import { SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer';
import { ProjectResource } from '../../models/project';
import { ResourceKind } from '../../models/resource';
}
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: "",
toggleOpen={this.toggleSidePanelOpen}
toggleActive={this.toggleSidePanelActive}
sidePanelItems={this.props.sidePanelItems}
- onContextMenu={(event) => this.openContextMenu(event, authService.getUuid() || "")}>
+ onContextMenu={(event) => this.openContextMenu(event, authService.getUuid() || "", ContextMenuKind.RootProject)}>
<ProjectTree
projects={this.props.projects}
toggleOpen={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.OPEN))}
- onContextMenu={(event, item) => this.openContextMenu(event, item.data.uuid)}
+ onContextMenu={(event, item) => this.openContextMenu(event, item.data.uuid, ContextMenuKind.Project)}
toggleActive={itemId => {
this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE));
this.props.dispatch<any>(loadDetails(itemId, ResourceKind.Project));
</div>
<DetailsPanel />
</main>
- <ContextMenu
- anchorEl={this.state.contextMenu.anchorEl}
- actions={contextMenuActions}
- onActionClick={this.openCreateDialog}
- onClose={this.closeContextMenu} />
+ <ContextMenu />
<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)}
+ onContextMenu={(event, item) => this.openContextMenu(event, item.uuid, ContextMenuKind.Project)}
onDialogOpen={this.handleCreationDialogOpen}
onItemClick={item => {
this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
},
onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: NavBreadcrumb) => {
- this.openContextMenu(event, breadcrumb.itemId);
+ this.openContextMenu(event, breadcrumb.itemId, ContextMenuKind.Project);
}
};
}
handleCreationDialogOpen = (itemUuid: string) => {
- this.closeContextMenu();
this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
}
-
- openContextMenu = (event: React.MouseEvent<HTMLElement>, itemUuid: string) => {
+ openContextMenu = (event: React.MouseEvent<HTMLElement>, itemUuid: string, kind: ContextMenuKind) => {
event.preventDefault();
- this.setState({
- contextMenu: {
- anchorEl: mockAnchorFromMouseEvent(event),
- itemUuid
- }
- });
- }
-
- closeContextMenu = () => {
- this.setState({ contextMenu: {} });
+ this.props.dispatch(
+ contextMenuActions.OPEN_CONTEXT_MENU({
+ position: { x: event.clientX, y: event.clientY },
+ resource: { uuid: itemUuid, kind }
+ })
+ );
}
- openCreateDialog = (item: ContextMenuAction) => {
- const { itemUuid } = this.state.contextMenu;
- if (item.openCreateDialog && itemUuid) {
- this.handleCreationDialogOpen(itemUuid);
- }
- }
-}
-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"
}
-]];
const drawerWidth = 240;
const appBarHeight = 100;