merge master
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 24 Jul 2018 11:19:03 +0000 (13:19 +0200)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 24 Jul 2018 11:19:03 +0000 (13:19 +0200)
Feature #13781

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

24 files changed:
src/common/api/common-resource-service.test.ts
src/components/context-menu/context-menu.test.tsx
src/components/context-menu/context-menu.tsx
src/index.tsx
src/models/link.ts [new file with mode: 0644]
src/services/favorite-service/favorite-service.test.ts [new file with mode: 0644]
src/services/favorite-service/favorite-service.ts [new file with mode: 0644]
src/services/link-service/link-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/context-menu/context-menu-reducer.ts
src/store/favorites/favorites-actions.ts [new file with mode: 0644]
src/store/favorites/favorites-reducer.ts [new file with mode: 0644]
src/store/project-panel/project-panel-middleware.ts
src/store/project/project-action.ts
src/store/store.ts
src/views-components/context-menu/action-sets/favorite-action.tsx [new file with mode: 0644]
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/resource-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/current-token-dialog/current-token-dialog.tsx [new file with mode: 0644]
src/views-components/favorite-star/favorite-star.tsx [new file with mode: 0644]
src/views-components/main-app-bar/main-app-bar.test.tsx
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.tsx

index 8346624550edc4af8149ab81c0838eb02405a640..d909c092aa8bad0a37ac0308a4da8ca80a872585 100644 (file)
@@ -3,8 +3,17 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { CommonResourceService } from "./common-resource-service";
-import axios from "axios";
+import axios, { AxiosInstance } from "axios";
 import MockAdapter from "axios-mock-adapter";
+import { Resource } from "../../models/resource";
+
+export const mockResourceService = <R extends Resource, C extends CommonResourceService<R>>(Service: new (client: AxiosInstance) => C) => {
+    const axiosInstance = axios.create();
+    const axiosMock = new MockAdapter(axiosInstance);
+    const service = new Service(axiosInstance);
+    Object.keys(service).map(key => service[key] = jest.fn());
+    return service;
+};
 
 describe("CommonResourceService", () => {
     const axiosInstance = axios.create();
index a245253858c8f5e7a1cfe213f62debbe9c0c404b..5ced213a3fb69435f42b1d3a8ba9bc9e93610df9 100644 (file)
@@ -27,6 +27,7 @@ describe("<ContextMenu />", () => {
         const onItemClick = jest.fn();
         const contextMenu = mount(<ContextMenu
             anchorEl={document.createElement("div")}
+            open={true}
             onClose={jest.fn()}
             onItemClick={onItemClick}
             items={items} />);
index 2103a2a09c1211b1123ee8d93088fae8516cb9af..95bbeafb4f23774c4a358b19282a60120375f751 100644 (file)
@@ -7,8 +7,9 @@ import { DefaultTransformOrigin } from "../popover/helpers";
 import { IconType } from "../icon/icon";
 
 export interface ContextMenuItem {
-    name: string;
-    icon: IconType;
+    name?: string | React.ComponentType;
+    icon?: IconType;
+    component?: React.ComponentType<any>;
 }
 
 export type ContextMenuItemGroup = ContextMenuItem[];
@@ -16,16 +17,17 @@ export type ContextMenuItemGroup = ContextMenuItem[];
 export interface ContextMenuProps {
     anchorEl?: HTMLElement;
     items: ContextMenuItemGroup[];
+    open: boolean;
     onItemClick: (action: ContextMenuItem) => void;
     onClose: () => void;
 }
 
 export class ContextMenu extends React.PureComponent<ContextMenuProps> {
     render() {
-        const { anchorEl, items, onClose, onItemClick} = this.props;
+        const { anchorEl, items, open, onClose, onItemClick } = this.props;
         return <Popover
             anchorEl={anchorEl}
-            open={!!anchorEl}
+            open={open}
             onClose={onClose}
             transformOrigin={DefaultTransformOrigin}
             anchorOrigin={DefaultTransformOrigin}
@@ -38,12 +40,16 @@ export class ContextMenu extends React.PureComponent<ContextMenuProps> {
                                 button
                                 key={actionIndex}
                                 onClick={() => onItemClick(item)}>
-                                <ListItemIcon>
-                                    <item.icon/>
-                                </ListItemIcon>
-                                <ListItemText>
-                                    {item.name}
-                                </ListItemText>
+                                {item.icon &&
+                                    <ListItemIcon>
+                                        <item.icon />
+                                    </ListItemIcon>}
+                                {item.name &&
+                                    <ListItemText>
+                                        {item.name}
+                                    </ListItemText>}
+                                {item.component &&
+                                    <item.component />}
                             </ListItem>)}
                         {groupIndex < items.length - 1 && <Divider />}
                     </React.Fragment>)}
index 6d53e0d439bf52f05d7d7b639adbd91dc06e2489..caba632c04225f4d7178dd52f469ad7f1269cf7d 100644 (file)
@@ -22,9 +22,11 @@ import { setBaseUrl } from './common/api/server-api';
 import { addMenuActionSet, ContextMenuKind } from "./views-components/context-menu/context-menu";
 import { rootProjectActionSet } from "./views-components/context-menu/action-sets/root-project-action-set";
 import { projectActionSet } from "./views-components/context-menu/action-sets/project-action-set";
+import { resourceActionSet } from './views-components/context-menu/action-sets/resource-action-set';
 
 addMenuActionSet(ContextMenuKind.RootProject, rootProjectActionSet);
 addMenuActionSet(ContextMenuKind.Project, projectActionSet);
+addMenuActionSet(ContextMenuKind.Resource, resourceActionSet);
 
 fetchConfig()
     .then(config => {
diff --git a/src/models/link.ts b/src/models/link.ts
new file mode 100644 (file)
index 0000000..8686528
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "./resource";
+
+export interface LinkResource extends Resource {
+    headUuid: string;
+    tailUuid: string;
+    linkClass: string;
+    name: string;
+    properties: {};
+}
+
+export enum LinkClass {
+    STAR = 'star'
+}
\ No newline at end of file
diff --git a/src/services/favorite-service/favorite-service.test.ts b/src/services/favorite-service/favorite-service.test.ts
new file mode 100644 (file)
index 0000000..e3a3bdf
--- /dev/null
@@ -0,0 +1,93 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { GroupsService, GroupContentsResource } from "../groups-service/groups-service";
+import { FavoriteService } from "./favorite-service";
+import { LinkClass, LinkResource } from "../../models/link";
+import { mockResourceService } from "../../common/api/common-resource-service.test";
+import { FilterBuilder } from "../../common/api/filter-builder";
+
+describe("FavoriteService", () => {
+
+    let linkService: LinkService;
+    let groupService: GroupsService;
+
+    beforeEach(() => {
+        linkService = mockResourceService(LinkService);
+        groupService = mockResourceService(GroupsService);
+    });
+
+    it("marks resource as favorite", async () => {
+        linkService.create = jest.fn().mockReturnValue(Promise.resolve({ uuid: "newUuid" }));
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const newFavorite = await favoriteService.create({ userUuid: "userUuid", resource: { uuid: "resourceUuid", name: "resource" } });
+
+        expect(linkService.create).toHaveBeenCalledWith({
+            ownerUuid: "userUuid",
+            tailUuid: "userUuid",
+            headUuid: "resourceUuid",
+            linkClass: LinkClass.STAR,
+            name: "resource"
+        });
+        expect(newFavorite.uuid).toEqual("newUuid");
+
+    });
+
+    it("unmarks resource as favorite", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "linkUuid" }] }));
+        const filters = FilterBuilder
+            .create<LinkResource>()
+            .addEqual('tailUuid', "userUuid")
+            .addEqual('headUuid', "resourceUuid")
+            .addEqual('linkClass', LinkClass.STAR);
+        linkService.list = list;
+        linkService.delete = jest.fn().mockReturnValue(Promise.resolve({ uuid: "linkUuid" }));
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const newFavorite = await favoriteService.delete({ userUuid: "userUuid", resourceUuid: "resourceUuid" });
+
+        expect(list.mock.calls[0][0].filters.getFilters()).toEqual(filters.getFilters());
+        expect(linkService.delete).toHaveBeenCalledWith("linkUuid");
+        expect(newFavorite[0].uuid).toEqual("linkUuid");
+    });
+
+    it("lists favorite resources", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "headUuid" }] }));
+        const listFilters = FilterBuilder
+            .create<LinkResource>()
+            .addEqual('tailUuid', "userUuid")
+            .addEqual('linkClass', LinkClass.STAR);
+        const contents = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "resourceUuid" }] }));
+        const contentFilters = FilterBuilder.create<GroupContentsResource>().addIn('uuid', ["headUuid"]);
+        linkService.list = list;
+        groupService.contents = contents;
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const favorites = await favoriteService.list("userUuid");
+
+        expect(list.mock.calls[0][0].filters.getFilters()).toEqual(listFilters.getFilters());
+        expect(contents.mock.calls[0][0]).toEqual("userUuid");
+        expect(contents.mock.calls[0][1].filters.getFilters()).toEqual(contentFilters.getFilters());
+        expect(favorites).toEqual({ items: [{ uuid: "resourceUuid" }] });
+    });
+
+    it("checks if resources are present in favorites", async () => {
+        const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "foo" }] }));
+        const listFilters = FilterBuilder
+            .create<LinkResource>()
+            .addIn("headUuid", ["foo", "oof"])
+            .addEqual("tailUuid", "userUuid")
+            .addEqual("linkClass", LinkClass.STAR);
+        linkService.list = list;
+        const favoriteService = new FavoriteService(linkService, groupService);
+
+        const favorites = await favoriteService.checkPresenceInFavorites("userUuid", ["foo", "oof"]);
+
+        expect(list.mock.calls[0][0].filters.getFilters()).toEqual(listFilters.getFilters());
+        expect(favorites).toEqual({ foo: true, oof: false });
+    });
+
+});
diff --git a/src/services/favorite-service/favorite-service.ts b/src/services/favorite-service/favorite-service.ts
new file mode 100644 (file)
index 0000000..6ceaa36
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { GroupsService, GroupContentsResource } from "../groups-service/groups-service";
+import { LinkResource, LinkClass } from "../../models/link";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { ListArguments, ListResults } from "../../common/api/common-resource-service";
+import { OrderBuilder } from "../../common/api/order-builder";
+
+export interface FavoriteListArguments extends ListArguments {
+    filters?: FilterBuilder<LinkResource>;
+    order?: OrderBuilder<LinkResource>;
+}
+export class FavoriteService {
+    constructor(
+        private linkService: LinkService,
+        private groupsService: GroupsService
+    ) { }
+
+    create(data: { userUuid: string; resource: { uuid: string; name: string } }) {
+        return this.linkService.create({
+            ownerUuid: data.userUuid,
+            tailUuid: data.userUuid,
+            headUuid: data.resource.uuid,
+            linkClass: LinkClass.STAR,
+            name: data.resource.name
+        });
+    }
+
+    delete(data: { userUuid: string; resourceUuid: string; }) {
+        return this.linkService
+            .list({
+                filters: FilterBuilder
+                    .create<LinkResource>()
+                    .addEqual('tailUuid', data.userUuid)
+                    .addEqual('headUuid', data.resourceUuid)
+                    .addEqual('linkClass', LinkClass.STAR)
+            })
+            .then(results => Promise.all(
+                results.items.map(item => this.linkService.delete(item.uuid))));
+    }
+
+    list(userUuid: string, args: FavoriteListArguments = {}): Promise<ListResults<GroupContentsResource>> {
+        const listFilter = FilterBuilder
+            .create<LinkResource>()
+            .addEqual('tailUuid', userUuid)
+            .addEqual('linkClass', LinkClass.STAR);
+
+        return this.linkService
+            .list({
+                ...args,
+                filters: args.filters ? args.filters.concat(listFilter) : listFilter
+            })
+            .then(results => {
+                const uuids = results.items.map(item => item.headUuid);
+                return this.groupsService.contents(userUuid, {
+                    limit: args.limit,
+                    offset: args.offset,
+                    filters: FilterBuilder.create<GroupContentsResource>().addIn('uuid', uuids),
+                    recursive: true
+                });
+            });
+    }
+
+    checkPresenceInFavorites(userUuid: string, resourceUuids: string[]): Promise<Record<string, boolean>> {
+        return this.linkService
+            .list({
+                filters: FilterBuilder
+                    .create<LinkResource>()
+                    .addIn("headUuid", resourceUuids)
+                    .addEqual("tailUuid", userUuid)
+                    .addEqual("linkClass", LinkClass.STAR)
+            })
+            .then(({ items }) => resourceUuids.reduce((results, uuid) => {
+                const isFavorite = items.some(item => item.headUuid === uuid);
+                return { ...results, [uuid]: isFavorite };
+            }, {}));
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/services/link-service/link-service.ts b/src/services/link-service/link-service.ts
new file mode 100644 (file)
index 0000000..4c12cd0
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "../../common/api/common-resource-service";
+import { LinkResource } from "../../models/link";
+import { AxiosInstance } from "axios";
+
+export class LinkService extends CommonResourceService<LinkResource> {
+    constructor(serverApi: AxiosInstance) {
+        super(serverApi, "links");
+    }
+}
\ No newline at end of file
index 57f07d6c6a579550de2ab141fdbace8e0afebe03..f0afd76fc82f7158f3e85aa9ddcb95b0d1c2df65 100644 (file)
@@ -6,7 +6,11 @@ import { AuthService } from "./auth-service/auth-service";
 import { GroupsService } from "./groups-service/groups-service";
 import { serverApi } from "../common/api/server-api";
 import { ProjectService } from "./project-service/project-service";
+import { LinkService } from "./link-service/link-service";
+import { FavoriteService } from "./favorite-service/favorite-service";
 
 export const authService = new AuthService(serverApi);
 export const groupsService = new GroupsService(serverApi);
 export const projectService = new ProjectService(serverApi);
+export const linkService = new LinkService(serverApi);
+export const favoriteService = new FavoriteService(linkService, groupsService);
index b20ad723f23d9aec615ab4700ca48db90be9f840..7ce2b3e75449a705a1627cc4b62457eebc561d8b 100644 (file)
@@ -6,6 +6,7 @@ import { ResourceKind } from "../../models/resource";
 import { contextMenuActions, ContextMenuAction } from "./context-menu-actions";
 
 export interface ContextMenuState {
+    open: boolean;
     position: ContextMenuPosition;
     resource?: ContextMenuResource;
 }
@@ -18,16 +19,18 @@ export interface ContextMenuPosition {
 export interface ContextMenuResource {
     uuid: string;
     kind: string;
+    name: string;
 }
 
 const initialState = {
+    open: false,
     position: { x: 0, y: 0 }
 };
 
 export const contextMenuReducer = (state: ContextMenuState = initialState, action: ContextMenuAction) =>
     contextMenuActions.match(action, {
         default: () => state,
-        OPEN_CONTEXT_MENU: ({resource, position}) => ({ resource, position }),
-        CLOSE_CONTEXT_MENU: () => ({ position: state.position })
+        OPEN_CONTEXT_MENU: ({ resource, position }) => ({ open: true, resource, position }),
+        CLOSE_CONTEXT_MENU: () => ({ ...state, open: false })
     });
 
diff --git a/src/store/favorites/favorites-actions.ts b/src/store/favorites/favorites-actions.ts
new file mode 100644 (file)
index 0000000..c38f4d1
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+import { favoriteService } from "../../services/services";
+import { RootState } from "../store";
+import { checkFavorite } from "./favorites-reducer";
+
+export const favoritesActions = unionize({
+    TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(),
+    CHECK_PRESENCE_IN_FAVORITES: ofType<string[]>(),
+    UPDATE_FAVORITES: ofType<Record<string, boolean>>()
+}, { tag: 'type', value: 'payload' });
+
+export type FavoritesAction = UnionOf<typeof favoritesActions>;
+
+export const toggleFavorite = (resource: { uuid: string; name: string }) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const userUuid = getState().auth.user!.uuid;
+        dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
+        const isFavorite = checkFavorite(resource.uuid, getState().favorites);
+        const promise: (any) = isFavorite
+            ? favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
+            : favoriteService.create({ userUuid, resource });
+
+        promise
+            .then(() => {
+                dispatch(favoritesActions.UPDATE_FAVORITES({ [resource.uuid]: !isFavorite }));
+            });
+    };
+
+export const checkPresenceInFavorites = (resourceUuids: string[]) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const userUuid = getState().auth.user!.uuid;
+        dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids));
+        favoriteService
+            .checkPresenceInFavorites(userUuid, resourceUuids)
+            .then(results => {
+                dispatch(favoritesActions.UPDATE_FAVORITES(results));
+            });
+    };
+
diff --git a/src/store/favorites/favorites-reducer.ts b/src/store/favorites/favorites-reducer.ts
new file mode 100644 (file)
index 0000000..e38ea95
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { FavoritesAction, favoritesActions } from "./favorites-actions";
+
+export type FavoritesState = Record<string, boolean>;
+
+export const favoritesReducer = (state: FavoritesState = {}, action: FavoritesAction) => 
+    favoritesActions.match(action, {
+        UPDATE_FAVORITES: favorites => ({...state, ...favorites}),
+        default: () => state
+    });
+
+export const checkFavorite = (uuid: string, state: FavoritesState) => state[uuid] === true;
\ No newline at end of file
index fbed1783e260c45ae906f48529c9c55ef040a784..faa22f0b85a9115165f860159e16f692b5dea508 100644 (file)
@@ -15,6 +15,7 @@ import { ProcessResource } from "../../models/process";
 import { OrderBuilder } from "../../common/api/order-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "../../services/groups-service/groups-service";
 import { SortDirection } from "../../components/data-table/data-column";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
 
 export const projectPanelMiddleware: Middleware = store => next => {
     next(dataExplorerActions.SET_COLUMNS({ id: PROJECT_PANEL_ID, columns }));
@@ -83,6 +84,7 @@ export const projectPanelMiddleware: Middleware = store => next => {
                                 page: Math.floor(response.offset / response.limit),
                                 rowsPerPage: response.limit
                             }));
+                            store.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
                         });
                 } else {
                     store.dispatch(dataExplorerActions.SET_ITEMS({
index 075e77d15483746a751d59553299546f01bd1460..cf38456109be0b25625214773f771c5eabc51713 100644 (file)
@@ -8,6 +8,7 @@ import { projectService } from "../../services/services";
 import { Dispatch } from "redux";
 import { FilterBuilder } from "../../common/api/filter-builder";
 import { RootState } from "../store";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
 
 export const projectActions = unionize({
     OPEN_PROJECT_CREATOR: ofType<{ ownerUuid: string }>(),
@@ -25,7 +26,7 @@ export const projectActions = unionize({
         value: 'payload'
     });
 
-export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch) => {
+export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch, getState: () => RootState) => {
     dispatch(projectActions.PROJECTS_REQUEST(parentUuid));
     return projectService.list({
         filters: FilterBuilder
@@ -33,6 +34,7 @@ export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch)
             .addEqual("ownerUuid", parentUuid)
     }).then(({ items: projects }) => {
         dispatch(projectActions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
+        dispatch<any>(checkPresenceInFavorites(projects.map(project => project.uuid)));
         return projects;
     });
 };
index 01b06b9528a727cd3cb9642a16bffeb0e17954ea..8a5136c91add44b378db23d3f9439d53d5496cd5 100644 (file)
@@ -15,6 +15,7 @@ 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';
 import { reducer as formReducer } from 'redux-form';
+import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -29,6 +30,7 @@ export interface RootState {
     sidePanel: SidePanelState;
     detailsPanel: DetailsPanelState;
     contextMenu: ContextMenuState;
+    favorites: FavoritesState;
 }
 
 const rootReducer = combineReducers({
@@ -39,7 +41,8 @@ const rootReducer = combineReducers({
     sidePanel: sidePanelReducer,
     detailsPanel: detailsPanelReducer,
     contextMenu: contextMenuReducer,
-    form: formReducer
+    form: formReducer,
+    favorites: favoritesReducer,
 });
 
 
diff --git a/src/views-components/context-menu/action-sets/favorite-action.tsx b/src/views-components/context-menu/action-sets/favorite-action.tsx
new file mode 100644 (file)
index 0000000..a4cf4e3
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { ListItemIcon, ListItemText } from "@material-ui/core";
+import { FavoriteIcon, AddFavoriteIcon, RemoveFavoriteIcon } from "../../../components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+
+const mapStateToProps = (state: RootState) => ({
+    isFavorite: state.contextMenu.resource && state.favorites[state.contextMenu.resource.uuid] === true
+});
+
+export const ToggleFavoriteAction = connect(mapStateToProps)((props: { isFavorite: boolean }) =>
+    <>
+        <ListItemIcon>
+            {props.isFavorite
+                ? <RemoveFavoriteIcon />
+                : <AddFavoriteIcon />}
+        </ListItemIcon>
+        <ListItemText>
+            {props.isFavorite
+                ? <>Remove from favorites</>
+                : <>Add to favorites</>}
+        </ListItemText>
+    </>);
index 66dbd4d16c5ba30034d5de8283ff5d8f4ea6c712..e0a5e5412616a8690df6e5de1184e46cf201a275 100644 (file)
@@ -4,7 +4,9 @@
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { projectActions } from "../../../store/project/project-action";
-import { ShareIcon, NewProjectIcon  } from "../../../components/icon/icon";
+import { NewProjectIcon } from "../../../components/icon/icon";
+import { ToggleFavoriteAction } from "./favorite-action";
+import { toggleFavorite } from "../../../store/favorites/favorites-actions";
 
 export const projectActionSet: ContextMenuActionSet = [[{
     icon: NewProjectIcon,
@@ -13,7 +15,8 @@ export const projectActionSet: ContextMenuActionSet = [[{
         dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: resource.uuid }));
     }
 }, {
-    icon: ShareIcon,
-    name: "Share",
-    execute: () => { return; }
+    component: ToggleFavoriteAction,
+    execute: (dispatch, resource) => {
+        dispatch<any>(toggleFavorite(resource));
+    }
 }]];
diff --git a/src/views-components/context-menu/action-sets/resource-action-set.ts b/src/views-components/context-menu/action-sets/resource-action-set.ts
new file mode 100644 (file)
index 0000000..8915283
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ToggleFavoriteAction } from "./favorite-action";
+import { toggleFavorite } from "../../../store/favorites/favorites-actions";
+
+export const resourceActionSet: ContextMenuActionSet = [[{
+    component: ToggleFavoriteAction,
+    execute: (dispatch, resource) => {
+        dispatch<any>(toggleFavorite(resource));
+    }
+}]];
index cc2fcb31b71a8b3e7416ef8e2f952df0a3b9bddc..245fe35e9459106179caaba38e0ef81b6085408c 100644 (file)
@@ -11,12 +11,13 @@ import { ContextMenuResource } from "../../store/context-menu/context-menu-reduc
 import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
 import { Dispatch } from "redux";
 
-type DataProps = Pick<ContextMenuProps, "anchorEl" | "items"> & { resource?: ContextMenuResource };
+type DataProps = Pick<ContextMenuProps, "anchorEl" | "items" | "open"> & { resource?: ContextMenuResource };
 const mapStateToProps = (state: RootState): DataProps => {
-    const { position, resource } = state.contextMenu;
+    const { open, position, resource } = state.contextMenu;
     return {
         anchorEl: resource ? createAnchorAt(position) : undefined,
         items: getMenuActionSet(resource),
+        open,
         resource
     };
 };
@@ -56,5 +57,6 @@ const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet
 
 export enum ContextMenuKind {
     RootProject = "RootProject",
-    Project = "Project"
+    Project = "Project",
+    Resource = "Resource"
 }
diff --git a/src/views-components/current-token-dialog/current-token-dialog.tsx b/src/views-components/current-token-dialog/current-token-dialog.tsx
new file mode 100644 (file)
index 0000000..fe5f850
--- /dev/null
@@ -0,0 +1,90 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography, Paper } from '@material-ui/core';
+import { ArvadosTheme } from '../../common/custom-theme';
+
+type CssRules = 'link' | 'paper' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    paper: {
+        padding: theme.spacing.unit,
+        marginBottom: theme.spacing.unit * 2,
+        backgroundColor: theme.palette.grey["200"],
+        border: `1px solid ${theme.palette.grey["300"]}`
+    },
+    button: {
+        fontSize: '0.8125rem',
+        fontWeight: 600
+    }
+});
+
+interface CurrentTokenDataProps {
+    currentToken?: string; 
+    open: boolean;
+}
+
+interface CurrentTokenActionProps {
+    handleClose: () => void;
+}
+
+type CurrentTokenProps = CurrentTokenDataProps & CurrentTokenActionProps & WithStyles<CssRules>;
+
+export const CurrentTokenDialog = withStyles(styles)(    
+    class extends React.Component<CurrentTokenProps> {
+        
+        render() {
+            const { classes, open, handleClose, currentToken } = this.props;
+            return (
+                <Dialog open={open} onClose={handleClose} fullWidth={true} maxWidth='md'>
+                    <DialogTitle>Current Token</DialogTitle>
+                    <DialogContent>
+                        <Typography variant='body1' paragraph={true}>
+                            The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions. 
+                            <Typography component='p'>
+                                For more information see
+                                <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
+                                    Getting an API token.
+                                </a>
+                            </Typography>
+                        </Typography>
+
+                        <Typography variant='body1' paragraph={true}> 
+                            Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your klingenc account.
+                        </Typography>
+
+                        <Paper className={classes.paper} elevation={0}>
+                            <Typography variant='body1'>
+                                HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'                            
+                            </Typography>
+                            <Typography variant='body1'>
+                                export ARVADOS_API_TOKEN={currentToken}
+                            </Typography>
+                            <Typography variant='body1'>
+                                export ARVADOS_API_HOST=api.ardev.roche.com
+                            </Typography>
+                            <Typography variant='body1'>
+                                unset ARVADOS_API_HOST_INSECURE
+                            </Typography>
+                        </Paper>
+                        <Typography variant='body1'>
+                            Arvados 
+                            <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a> 
+                            do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
+                        </Typography>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={handleClose} className={classes.button} color="primary">CLOSE</Button>
+                    </DialogActions>
+                </Dialog>
+            );
+        }
+    }
+);
\ No newline at end of file
diff --git a/src/views-components/favorite-star/favorite-star.tsx b/src/views-components/favorite-star/favorite-star.tsx
new file mode 100644 (file)
index 0000000..f896e30
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { FavoriteIcon } from "../../components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "../../store/store";
+import { withStyles, StyleRulesCallback, WithStyles } from "@material-ui/core";
+
+type CssRules = "icon";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    icon: {
+        fontSize: "inherit"
+    }
+});
+
+const mapStateToProps = (state: RootState, props: { resourceUuid: string; className?: string; }) => ({
+    ...props,
+    visible: state.favorites[props.resourceUuid],
+});
+
+export const FavoriteStar = connect(mapStateToProps)(
+    withStyles(styles)((props: { visible: boolean; className?: string; } & WithStyles<CssRules>) =>
+        props.visible ? <FavoriteIcon className={props.className || props.classes.icon} /> : null
+    ));
\ No newline at end of file
index 6d5c9de897162288c5936d028f07dcb1dfaee88b..a634d43955e47f581a259d64eb192ca72fb95055 100644 (file)
@@ -30,7 +30,6 @@ describe("<MainAppBar />", () => {
                 user={user}
                 onContextMenu={jest.fn()}
                 onDetailsPanelToggle={jest.fn()}
-                onContextMenu={jest.fn()}
                 {...{ searchText: "", breadcrumbs: [], menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onSearch: jest.fn(), onBreadcrumbClick: jest.fn(), onMenuItemClick: jest.fn() }}
             />
         );
@@ -63,7 +62,6 @@ describe("<MainAppBar />", () => {
                 searchDebounce={2000}
                 onContextMenu={jest.fn()}
                 onSearch={onSearch}
-                onContextMenu={jest.fn()}
                 onDetailsPanelToggle={jest.fn()}
                 {...{ user, breadcrumbs: [], menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onBreadcrumbClick: jest.fn(), onMenuItemClick: jest.fn() }}
             />
@@ -83,7 +81,6 @@ describe("<MainAppBar />", () => {
                 breadcrumbs={items}
                 onContextMenu={jest.fn()}
                 onBreadcrumbClick={onBreadcrumbClick}
-                onContextMenu={jest.fn()}
                 onDetailsPanelToggle={jest.fn()}
                 {...{ user, searchText: "", menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onSearch: jest.fn(), onMenuItemClick: jest.fn() }}
             />
@@ -102,7 +99,6 @@ describe("<MainAppBar />", () => {
                 menuItems={menuItems}
                 onContextMenu={jest.fn()}
                 onMenuItemClick={onMenuItemClick}
-                onContextMenu={jest.fn()}
                 onDetailsPanelToggle={jest.fn()}
                 {...{ user, searchText: "", breadcrumbs: [], onSearch: jest.fn(), onBreadcrumbClick: jest.fn() }}
             />
index daf22b11faca3b02e87441a625cf50f513f84191..c2b42a55deb4de79b35abae49b2c333e0e69cb30 100644 (file)
@@ -16,8 +16,9 @@ import { ContainerRequestState } from '../../models/container-request';
 import { SortDirection } from '../../components/data-table/data-column';
 import { ResourceKind } from '../../models/resource';
 import { resourceLabel } from '../../common/labels';
-import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '../../components/icon/icon';
+import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, FavoriteIcon } from '../../components/icon/icon';
 import { ArvadosTheme } from '../../common/custom-theme';
+import { FavoriteStar } from '../../views-components/favorite-star/favorite-star';
 
 type CssRules = "toolbar" | "button";
 
@@ -41,6 +42,11 @@ const renderName = (item: ProjectPanelItem) =>
                 {item.name}
             </Typography>
         </Grid>
+        <Grid item>
+            <Typography variant="caption">
+                <FavoriteStar resourceUuid={item.uuid} />
+            </Typography>
+        </Grid>
     </Grid>;
 
 
@@ -184,7 +190,7 @@ interface ProjectPanelActionProps {
 }
 
 type ProjectPanelProps = ProjectPanelDataProps & ProjectPanelActionProps & DispatchProp
-                        & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 export const ProjectPanel = withStyles(styles)(
     connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
index b1e7cd78659efe4cfa88239adaa591d7cda4813b..f3ad839d3502b2102e6225c057a1b51e8c7021f7 100644 (file)
@@ -32,6 +32,7 @@ import { SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer'
 import { ProjectResource } from '../../models/project';
 import { ResourceKind } from '../../models/resource';
 import { ContextMenu, ContextMenuKind } from "../../views-components/context-menu/context-menu";
+import { CurrentTokenDialog } from '../../views-components/current-token-dialog/current-token-dialog';
 
 const drawerWidth = 240;
 const appBarHeight = 100;
@@ -78,6 +79,7 @@ interface WorkbenchDataProps {
     projects: Array<TreeItem<ProjectResource>>;
     currentProjectId: string;
     user?: User;
+    currentToken?: string;
     sidePanelItems: SidePanelItem[];
 }
 
@@ -95,6 +97,7 @@ interface NavMenuItem extends MainAppBarMenuItem {
 }
 
 interface WorkbenchState {
+    isCurrentTokenDialogOpen: boolean;
     anchorEl: any;
     searchText: string;
     menuItems: {
@@ -110,17 +113,23 @@ export const Workbench = withStyles(styles)(
             projects: state.projects.items,
             currentProjectId: state.projects.currentItemId,
             user: state.auth.user,
+            currentToken: state.auth.apiToken,
             sidePanelItems: state.sidePanel
         })
     )(
         class extends React.Component<WorkbenchProps, WorkbenchState> {
             state = {
                 isCreationDialogOpen: false,
+                isCurrentTokenDialogOpen: false,
                 anchorEl: null,
                 searchText: "",
                 breadcrumbs: [],
                 menuItems: {
                     accountMenu: [
+                        {
+                            label: 'Current token',
+                            action: () => this.toggleCurrentTokenModal()
+                        },
                         {
                             label: "Logout",
                             action: () => this.props.dispatch(authActions.LOGOUT())
@@ -175,11 +184,19 @@ export const Workbench = withStyles(styles)(
                                     toggleOpen={this.toggleSidePanelOpen}
                                     toggleActive={this.toggleSidePanelActive}
                                     sidePanelItems={this.props.sidePanelItems}
-                                    onContextMenu={(event) => this.openContextMenu(event, authService.getUuid() || "", ContextMenuKind.RootProject)}>
+                                    onContextMenu={(event) => this.openContextMenu(event, {
+                                        uuid: authService.getUuid() || "",
+                                        name: "",
+                                        kind: 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, ContextMenuKind.Project)}
+                                        onContextMenu={(event, item) => this.openContextMenu(event, {
+                                            uuid: item.data.uuid,
+                                            name: item.data.name,
+                                            kind: ContextMenuKind.Project
+                                        })}
                                         toggleActive={itemId => {
                                             this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE));
                                             this.props.dispatch<any>(loadDetails(itemId, ResourceKind.Project));
@@ -197,13 +214,24 @@ export const Workbench = withStyles(styles)(
                         </main>
                         <ContextMenu />
                         <CreateProjectDialog />
+                        <CurrentTokenDialog 
+                            currentToken={this.props.currentToken}
+                            open={this.state.isCurrentTokenDialogOpen} 
+                            handleClose={this.toggleCurrentTokenModal} />
                     </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, ContextMenuKind.Project)}
+                onContextMenu={(event, item) => {
+                    const kind = item.kind === ResourceKind.Project ? ContextMenuKind.Project : ContextMenuKind.Resource;
+                    this.openContextMenu(event, {
+                        uuid: item.uuid,
+                        name: item.name,
+                        kind
+                    });
+                }}
                 onDialogOpen={this.handleCreationDialogOpen}
                 onItemClick={item => {
                     this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
@@ -228,7 +256,11 @@ export const Workbench = withStyles(styles)(
                     this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
                 },
                 onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: NavBreadcrumb) => {
-                    this.openContextMenu(event, breadcrumb.itemId, ContextMenuKind.Project);
+                    this.openContextMenu(event, {
+                        uuid: breadcrumb.itemId,
+                        name: breadcrumb.label,
+                        kind: ContextMenuKind.Project
+                    });
                 }
             };
 
@@ -246,15 +278,19 @@ export const Workbench = withStyles(styles)(
                 this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
             }
 
-            openContextMenu = (event: React.MouseEvent<HTMLElement>, itemUuid: string, kind: ContextMenuKind) => {
+            openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; kind: ContextMenuKind; }) => {
                 event.preventDefault();
                 this.props.dispatch(
                     contextMenuActions.OPEN_CONTEXT_MENU({
                         position: { x: event.clientX, y: event.clientY },
-                        resource: { uuid: itemUuid, kind }
+                        resource
                     })
                 );
             }
+
+            toggleCurrentTokenModal = () => {
+                this.setState({ isCurrentTokenDialogOpen: !this.state.isCurrentTokenDialogOpen });
+            }
         }
     )
 );