merge master
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 28 Aug 2018 11:08:29 +0000 (13:08 +0200)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 28 Aug 2018 11:08:29 +0000 (13:08 +0200)
Feature #13858

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

83 files changed:
src/common/unionize.ts [new file with mode: 0644]
src/components/breadcrumbs/breadcrumbs.tsx
src/components/side-panel/side-panel.tsx [deleted file]
src/components/tree/tree.tsx
src/index.tsx
src/models/resource.ts
src/models/user.ts
src/routes/routes.ts [new file with mode: 0644]
src/services/ancestors-service/ancestors-service.ts [new file with mode: 0644]
src/services/services.ts
src/services/user-service/user-service.ts [new file with mode: 0644]
src/store/auth/auth-action.ts
src/store/breadcrumbs/breadcrumbs-actions.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-create-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-update-actions.ts
src/store/collections/uploader/collection-uploader-actions.ts
src/store/context-menu/context-menu-actions.ts
src/store/data-explorer/data-explorer-action.ts
src/store/data-explorer/data-explorer-middleware-service.ts
src/store/details-panel/details-panel-action.ts
src/store/details-panel/details-panel-reducer.ts
src/store/dialog/dialog-actions.ts
src/store/favorite-panel/favorite-panel-action.ts
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/favorites/favorites-actions.ts
src/store/navigation/navigation-action.ts
src/store/project-panel/project-panel-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/project/project-action.ts [deleted file]
src/store/project/project-reducer.test.ts [deleted file]
src/store/project/project-reducer.ts [deleted file]
src/store/projects/project-create-actions.ts
src/store/projects/project-move-actions.ts
src/store/projects/project-update-actions.ts
src/store/properties/properties-actions.ts [new file with mode: 0644]
src/store/properties/properties-reducer.ts [new file with mode: 0644]
src/store/properties/properties.ts [new file with mode: 0644]
src/store/resources/resources-actions.ts [new file with mode: 0644]
src/store/resources/resources-reducer.ts [new file with mode: 0644]
src/store/resources/resources.ts [new file with mode: 0644]
src/store/side-panel-tree/side-panel-tree-actions.ts [new file with mode: 0644]
src/store/side-panel/side-panel-action.ts
src/store/side-panel/side-panel-reducer.test.ts [deleted file]
src/store/side-panel/side-panel-reducer.ts [deleted file]
src/store/snackbar/snackbar-actions.ts
src/store/snackbar/snackbar-reducer.ts
src/store/store.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/tree-picker/tree-picker.ts
src/store/workbench/workbench-actions.ts [new file with mode: 0644]
src/views-components/api-token/api-token.tsx
src/views-components/breadcrumbs/breadcrumbs.ts [new file with mode: 0644]
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/root-project-action-set.ts
src/views-components/data-explorer/data-explorer.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/dialog-forms/copy-collection-dialog.ts
src/views-components/dialog-forms/create-collection-dialog.ts
src/views-components/dialog-forms/create-project-dialog.ts
src/views-components/dialog-forms/move-collection-dialog.ts
src/views-components/dialog-forms/move-project-dialog.ts
src/views-components/dialog-forms/update-collection-dialog.ts
src/views-components/dialog-forms/update-project-dialog.ts
src/views-components/main-app-bar/main-app-bar.test.tsx
src/views-components/main-app-bar/main-app-bar.tsx
src/views-components/project-tree-picker/project-tree-picker.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx [new file with mode: 0644]
src/views-components/side-panel/side-panel.tsx [new file with mode: 0644]
src/views-components/tree-picker/tree-picker.ts
src/views/collection-panel/collection-panel.tsx
src/views/favorite-panel/favorite-panel-item.ts [deleted file]
src/views/favorite-panel/favorite-panel.tsx
src/views/process-panel/process-panel.tsx
src/views/project-panel/project-panel-item.ts [deleted file]
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.tsx

diff --git a/src/common/unionize.ts b/src/common/unionize.ts
new file mode 100644 (file)
index 0000000..b684431
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export * from 'unionize';
+import { unionize as originalUnionize, SingleValueRec } from 'unionize';
+
+export function unionize<Record extends SingleValueRec>(record: Record) {
+    return originalUnionize(record, {
+        tag: 'type',
+        value: 'payload'
+    });
+}
+
index da549dba46757a9932d7655f43c25e56431d4380..444ac75ef51b97c0f1df5ba8cf55b828bbf571c1 100644 (file)
@@ -25,7 +25,7 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-interface BreadcrumbsProps {
+export interface BreadcrumbsProps {
     items: Breadcrumb[];
     onClick: (breadcrumb: Breadcrumb) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx
deleted file mode 100644 (file)
index 84e5c54..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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/styles';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { List, ListItem, ListItemIcon, Collapse } from "@material-ui/core";
-import { SidePanelRightArrowIcon, IconType } from '../icon/icon';
-import * as classnames from "classnames";
-import { ListItemTextIcon } from '../list-item-text-icon/list-item-text-icon';
-import { Dispatch } from "redux";
-import { RouteComponentProps, withRouter } from "react-router";
-
-type CssRules = 'active' | 'row' | 'root' | 'list' | 'iconClose' | 'iconOpen' | 'toggableIconContainer' | 'toggableIcon';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        overflowY: 'auto',
-        minWidth: '240px',
-        whiteSpace: 'nowrap',
-        marginTop: '52px',
-        display: 'flex',
-        flexGrow: 1,
-    },
-    list: {
-        padding: '5px 0px 5px 14px',
-        minWidth: '240px',
-    },
-    row: {
-        display: 'flex',
-        alignItems: 'center',
-    },
-    toggableIconContainer: {
-        color: theme.palette.grey["700"],
-        height: '14px',
-        width: '14px'
-    },
-    toggableIcon: {
-        fontSize: '14px'
-    },
-    active: {
-        color: theme.palette.primary.main,
-    },
-    iconClose: {
-        transition: 'all 0.1s ease',
-    },
-    iconOpen: {
-        transition: 'all 0.1s ease',
-        transform: 'rotate(90deg)',
-    }
-});
-
-export interface SidePanelItem {
-    id: string;
-    name: string;
-    url: string;
-    icon: IconType;
-    open?: boolean;
-    margin?: boolean;
-    openAble?: boolean;
-    activeAction?: (dispatch: Dispatch, uuid?: string) => void;
-}
-
-interface SidePanelDataProps {
-    toggleOpen: (id: string) => void;
-    toggleActive: (id: string) => void;
-    sidePanelItems: SidePanelItem[];
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: SidePanelItem) => void;
-}
-
-type SidePanelProps = RouteComponentProps<{}> & SidePanelDataProps & WithStyles<CssRules>;
-
-export const SidePanel = withStyles(styles)(withRouter(
-    class extends React.Component<SidePanelProps> {
-        render() {
-            const { classes, toggleOpen, toggleActive, sidePanelItems, children } = this.props;
-            const { root, row, list, toggableIconContainer } = classes;
-
-            const path = this.props.location.pathname.split('/');
-            const activeUrl = path.length > 1 ? "/" + path[1] : "/";
-            return (
-                <div className={root}>
-                    <List>
-                        {sidePanelItems.map(it => {
-                            const active = it.url === activeUrl;
-                            return <span key={it.name}>
-                                <ListItem button className={list} onClick={() => toggleActive(it.id)}
-                                          onContextMenu={this.handleRowContextMenu(it)}>
-                                    <span className={row}>
-                                        {it.openAble ? (
-                                            <i onClick={() => toggleOpen(it.id)} className={toggableIconContainer}>
-                                                <ListItemIcon
-                                                    className={this.getToggableIconClassNames(it.open, active)}>
-                                                    < SidePanelRightArrowIcon/>
-                                                </ListItemIcon>
-                                            </i>
-                                        ) : null}
-                                        <ListItemTextIcon icon={it.icon} name={it.name} isActive={active}
-                                                          hasMargin={it.margin}/>
-                                    </span>
-                                </ListItem>
-                                {it.openAble ? (
-                                    <Collapse in={it.open} timeout="auto" unmountOnExit>
-                                        {children}
-                                    </Collapse>
-                                ) : null}
-                            </span>;
-                        })}
-                    </List>
-                </div>
-            );
-        }
-
-        getToggableIconClassNames = (isOpen?: boolean, isActive ?: boolean) => {
-            const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
-            return classnames(toggableIcon, {
-                [iconOpen]: isOpen,
-                [iconClose]: !isOpen,
-                [active]: isActive
-            });
-        }
-
-        handleRowContextMenu = (item: SidePanelItem) =>
-            (event: React.MouseEvent<HTMLElement>) =>
-                item.openAble ? this.props.onContextMenu(event, item) : null
-    }
-));
index 8d657f8dd63dabff570abafaa831d8411a9d1eab..c892d7d2c8fb12b86da3e4904b9dc9c8198148e1 100644 (file)
@@ -107,7 +107,7 @@ export const Tree = withStyles(styles)(
                             onContextMenu={this.handleRowContextMenu(it)}>
                             {it.status === TreeItemStatus.PENDING ?
                                 <CircularProgress size={10} className={loader} /> : null}
-                            <i onClick={() => this.props.toggleItemOpen(it.id, it.status)}
+                            <i onClick={this.handleToggleItemOpen(it.id, it.status)}
                                 className={toggableIconContainer}>
                                 <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
                                     {this.getProperArrowAnimation(it.status, it.items!)}
@@ -171,5 +171,10 @@ export const Tree = withStyles(styles)(
                 }
                 : undefined;
         }
+
+        handleToggleItemOpen = (id: string, status: TreeItemStatus) => (event: React.MouseEvent<HTMLElement>) => {
+            event.stopPropagation();
+            this.props.toggleItemOpen(id, status);
+        }
     }
 );
index c6a5da24a372f2f3adbb822435e69008de18a3bc..d3115a6754bf70feb6731e8910e71b04859534f4 100644 (file)
@@ -7,14 +7,14 @@ import * as ReactDOM from 'react-dom';
 import { Provider } from "react-redux";
 import { Workbench } from './views/workbench/workbench';
 import './index.css';
-import { Route } from "react-router";
+import { Route } from 'react-router';
 import createBrowserHistory from "history/createBrowserHistory";
-import { configureStore } from "./store/store";
+import { History } from "history";
+import { configureStore, RootStore } from './store/store';
 import { ConnectedRouter } from "react-router-redux";
 import { ApiToken } from "./views-components/api-token/api-token";
 import { initAuth } from "./store/auth/auth-action";
 import { createServices } from "./services/services";
-import { getProjectList } from "./store/project/project-action";
 import { MuiThemeProvider } from '@material-ui/core/styles';
 import { CustomTheme } from './common/custom-theme';
 import { fetchConfig } from './common/config';
@@ -28,6 +28,9 @@ import { collectionFilesItemActionSet } from './views-components/context-menu/ac
 import { collectionActionSet } from './views-components/context-menu/action-sets/collection-action-set';
 import { collectionResourceActionSet } from './views-components/context-menu/action-sets/collection-resource-action-set';
 import { processActionSet } from './views-components/context-menu/action-sets/process-action-set';
+import { addRouteChangeHandlers } from './routes/routes';
+import { loadWorkbench } from './store/workbench/workbench-actions';
+import { Routes } from '~/routes/routes';
 
 const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev");
 const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7);
@@ -48,24 +51,25 @@ addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSe
 addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
 
 fetchConfig()
-    .then(config => {
+    .then((config) => {
         const history = createBrowserHistory();
         const services = createServices(config);
         const store = configureStore(history, services);
 
+        store.subscribe(initListener(history, store));
+
         store.dispatch(initAuth());
-        store.dispatch(getProjectList(services.authService.getUuid()));  
 
-        const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props}/>;
-        const WorkbenchComponent = (props: any) => <Workbench authService={services.authService} buildInfo={buildInfo} {...props}/>;
+        const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props} />;
+        const WorkbenchComponent = (props: any) => <Workbench authService={services.authService} buildInfo={buildInfo} {...props} />;
 
         const App = () =>
             <MuiThemeProvider theme={CustomTheme}>
                 <Provider store={store}>
                     <ConnectedRouter history={history}>
                         <div>
-                            <Route path="/" component={WorkbenchComponent} />
-                            <Route path="/token" component={TokenComponent} />
+                            <Route path={Routes.TOKEN} component={TokenComponent} />
+                            <Route path={Routes.ROOT} component={WorkbenchComponent} />
                         </div>
                     </ConnectedRouter>
                 </Provider>
@@ -75,6 +79,20 @@ fetchConfig()
             <App />,
             document.getElementById('root') as HTMLElement
         );
+
+
     });
 
+const initListener = (history: History, store: RootStore) => {
+    let initialized = false;
+    return async () => {
+        const { router, auth } = store.getState();
+        if (router.location && auth.user && !initialized) {
+            initialized = true;
+            await store.dispatch(loadWorkbench());
+            addRouteChangeHandlers(history, store);
+        }
+    };
+};
+
 
index 6a76b070452146734b2a9f64a36bf3d4eac6ff4f..ff95c1a9b8df59872d6819dccdbcfb2b8ee92441 100644 (file)
@@ -20,5 +20,39 @@ export enum ResourceKind {
     GROUP = "arvados#group",
     PROCESS = "arvados#containerRequest",
     PROJECT = "arvados#group",
-    WORKFLOW = "arvados#workflow"
+    WORKFLOW = "arvados#workflow",
+    USER = "arvados#user",
 }
+
+export enum ResourceObjectType {
+    USER = 'tpzed',
+    GROUP = 'j7d0g',
+    COLLECTION = '4zz18'
+}
+
+export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}';
+export const RESOURCE_UUID_REGEX = new RegExp(RESOURCE_UUID_PATTERN);
+
+export const isResourceUuid = (uuid: string) =>
+    RESOURCE_UUID_REGEX.test(uuid);
+
+export const extractUuidObjectType = (uuid: string) => {
+    const match = RESOURCE_UUID_REGEX.exec(uuid);
+    return match
+        ? match[0].split('-')[1]
+        : undefined;
+};
+
+export const extractUuidKind = (uuid: string = '') => {
+    const objectType = extractUuidObjectType(uuid);
+    switch (objectType) {
+        case ResourceObjectType.USER:
+            return ResourceKind.USER;
+        case ResourceObjectType.GROUP:
+            return ResourceKind.GROUP;
+        case ResourceObjectType.COLLECTION:
+            return ResourceKind.COLLECTION;
+        default:
+            return undefined;
+    }
+};
index 4cc29ba779cc6dfd677c965d0c485e7bc18fb0fe..c2f21e582798dacd5597872696ff7fc1685d62e7 100644 (file)
@@ -2,6 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Resource, ResourceKind } from '~/models/resource';
+
 export interface User {
     email: string;
     firstName: string;
@@ -12,4 +14,18 @@ export interface User {
 
 export const getUserFullname = (user?: User) => {
     return user ? `${user.firstName} ${user.lastName}` : "";
-};
\ No newline at end of file
+};
+
+export interface UserResource extends Resource {
+    kind: ResourceKind.USER;
+    email: string;
+    username: string;
+    firstName: string;
+    lastName: string;
+    identityUrl: string;
+    isAdmin: boolean;
+    prefs: string;
+    defaultOwnerUuid: string;
+    isActive: boolean;
+    writableBy: string[];
+}
\ No newline at end of file
diff --git a/src/routes/routes.ts b/src/routes/routes.ts
new file mode 100644 (file)
index 0000000..0bf7110
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { History, Location } from 'history';
+import { RootStore } from '../store/store';
+import { matchPath } from 'react-router';
+import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind } from '~/models/resource';
+import { getProjectUrl } from '../models/project';
+import { getCollectionUrl } from '~/models/collection';
+import { loadProject, loadFavorites, loadCollection } from '../store/workbench/workbench-actions';
+
+export const Routes = {
+    ROOT: '/',
+    TOKEN: '/token',
+    PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`,
+    COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
+    PROCESS: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
+    FAVORITES: '/favorites',
+};
+
+export const getResourceUrl = (uuid: string) => {
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+            return getProjectUrl(uuid);
+        case ResourceKind.COLLECTION:
+            return getCollectionUrl(uuid);
+        default:
+            return undefined;
+    }
+};
+
+export const addRouteChangeHandlers = (history: History, store: RootStore) => {
+    const handler = handleLocationChange(store);
+    handler(history.location);
+    history.listen(handler);
+};
+
+export const matchRootRoute = (route: string) =>
+    matchPath(route, { path: Routes.ROOT, exact: true });
+
+export const matchFavoritesRoute = (route: string) =>
+    matchPath(route, { path: Routes.FAVORITES });
+
+export interface ProjectRouteParams {
+    id: string;
+}
+
+export const matchProjectRoute = (route: string) =>
+    matchPath<ProjectRouteParams>(route, { path: Routes.PROJECTS });
+
+export interface CollectionRouteParams {
+    id: string;
+}
+
+export const matchCollectionRoute = (route: string) =>
+    matchPath<CollectionRouteParams>(route, { path: Routes.COLLECTIONS });
+
+
+const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
+    const projectMatch = matchProjectRoute(pathname);
+    const collectionMatch = matchCollectionRoute(pathname);
+    const favoriteMatch = matchFavoritesRoute(pathname);
+    if (projectMatch) {
+        store.dispatch(loadProject(projectMatch.params.id));
+    } else if (collectionMatch) {
+        store.dispatch(loadCollection(collectionMatch.params.id));
+    } else if (favoriteMatch) {
+        store.dispatch(loadFavorites());
+    }
+};
diff --git a/src/services/ancestors-service/ancestors-service.ts b/src/services/ancestors-service/ancestors-service.ts
new file mode 100644 (file)
index 0000000..1cd42fb
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupsService } from "~/services/groups-service/groups-service";
+import { UserService } from '../user-service/user-service';
+import { GroupResource } from '~/models/group';
+import { UserResource } from '~/models/user';
+import { extractUuidObjectType, ResourceObjectType } from "~/models/resource";
+
+export class AncestorService {
+    constructor(
+        private groupsService: GroupsService,
+        private userService: UserService
+    ) { }
+
+    async ancestors(uuid: string, rootUuid: string): Promise<Array<UserResource | GroupResource>> {
+        const service = this.getService(extractUuidObjectType(uuid));
+        if (service) {
+            const resource = await service.get(uuid);
+            if (uuid === rootUuid) {
+                return [resource];
+            } else {
+                return [
+                    ...await this.ancestors(resource.ownerUuid, rootUuid),
+                    resource
+                ];
+            }
+        } else {
+            return [];
+        }
+    }
+
+    private getService = (objectType?: string) => {
+        switch (objectType) {
+            case ResourceObjectType.GROUP:
+                return this.groupsService;
+            case ResourceObjectType.USER:
+                return this.userService;
+            default:
+                return undefined;
+        }
+    }
+}
\ No newline at end of file
index 0e1f4b438bc239ee02578b9d8aebe751b18f096c..6295527bfb40d5c326b56a4a4d09dfe27d22e85e 100644 (file)
@@ -14,6 +14,9 @@ import { CollectionFilesService } from "./collection-files-service/collection-fi
 import { KeepService } from "./keep-service/keep-service";
 import { WebDAV } from "../common/webdav";
 import { Config } from "../common/config";
+import { UserService } from './user-service/user-service';
+import { AncestorService } from "~/services/ancestors-service/ancestors-service";
+import { ResourceKind } from "~/models/resource";
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -33,6 +36,8 @@ export const createServices = (config: Config) => {
     const collectionService = new CollectionService(apiClient, webdavClient, authService);
     const tagService = new TagService(linkService);
     const collectionFilesService = new CollectionFilesService(collectionService);
+    const userService = new UserService(apiClient);
+    const ancestorsService = new AncestorService(groupsService, userService);
 
     return {
         apiClient,
@@ -45,6 +50,21 @@ export const createServices = (config: Config) => {
         favoriteService,
         collectionService,
         tagService,
-        collectionFilesService
+        collectionFilesService,
+        userService,
+        ancestorsService,
     };
 };
+
+export const getResourceService = (kind?: ResourceKind) => (serviceRepository: ServiceRepository) => {
+    switch (kind) {
+        case ResourceKind.USER:
+            return serviceRepository.userService;
+        case ResourceKind.GROUP:
+            return serviceRepository.groupsService;
+        case ResourceKind.COLLECTION:
+            return serviceRepository.collectionService;
+        default:
+            return undefined;
+    }
+};
\ No newline at end of file
diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts
new file mode 100644 (file)
index 0000000..3c09a87
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "~/common/api/common-resource-service";
+import { UserResource } from "~/models/user";
+
+export class UserService extends CommonResourceService<UserResource> {
+    constructor(serverApi: AxiosInstance) {
+        super(serverApi, "users");
+    }
+}
\ No newline at end of file
index 00af5ce5b0bb7614f4fbc97316a61dd712759ba3..ac2e0b7e2f68c6e699e294710d582582701b5fc2 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ofType, default as unionize, UnionOf } from "unionize";
+import { ofType, unionize, UnionOf } from '~/common/unionize';
 import { Dispatch } from "redux";
 import { User } from "~/models/user";
 import { RootState } from "../store";
@@ -16,9 +16,6 @@ export const authActions = unionize({
     INIT: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>()
-}, {
-    tag: 'type',
-    value: 'payload'
 });
 
 function setAuthorizationHeader(services: ServiceRepository, token: string) {
diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts
new file mode 100644 (file)
index 0000000..254a8d3
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs';
+import { getResource } from '~/store/resources/resources';
+import { TreePicker } from '../tree-picker/tree-picker';
+import { getSidePanelTreeBranch } from '../side-panel-tree/side-panel-tree-actions';
+import { propertiesActions } from '../properties/properties-actions';
+
+export const BREADCRUMBS = 'breadcrumbs';
+
+export interface ResourceBreadcrumb extends Breadcrumb {
+    uuid: string;
+}
+
+export const setBreadcrumbs = (breadcrumbs: Breadcrumb[]) =>
+    propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs });
+
+const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): ResourceBreadcrumb[] => {
+    const nodes = getSidePanelTreeBranch(uuid)(treePicker);
+    return nodes.map(node =>
+        typeof node.value === 'string'
+            ? { label: node.value, uuid: node.nodeId }
+            : { label: node.value.name, uuid: node.value.uuid });
+};
+
+export const setSidePanelBreadcrumbs = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { treePicker } = getState();
+        const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker);
+        dispatch(setBreadcrumbs(breadcrumbs));
+    };
+
+export const setProjectBreadcrumbs = setSidePanelBreadcrumbs;
+
+export const setCollectionBreadcrumbs = (collectionUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { resources } = getState();
+        const collection = getResource(collectionUuid)(resources);
+        if (collection) {
+            dispatch<any>(setProjectBreadcrumbs(collection.ownerUuid));
+        }
+    };
index 06d4d2762288ee5b6a14108a7ac01e336242a494..5b2690bfaf7a763f8e56620a25eee66f598847d5 100644 (file)
@@ -2,16 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from "unionize";
 import { Dispatch } from "redux";
 import { loadCollectionFiles } from "./collection-panel-files/collection-panel-files-actions";
-import { CollectionResource } from "~/models/collection";
+import { CollectionResource } from '~/models/collection';
 import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions";
 import { createTree } from "~/models/tree";
 import { RootState } from "../store";
 import { ServiceRepository } from "~/services/services";
 import { TagResource, TagProperty } from "~/models/tag";
 import { snackbarActions } from "../snackbar/snackbar-actions";
+import { resourcesActions } from "~/store/resources/resources-actions";
+import { unionize, ofType, UnionOf } from '~/common/unionize';
 
 export const collectionPanelActions = unionize({
     LOAD_COLLECTION: ofType<{ uuid: string }>(),
@@ -22,22 +23,21 @@ export const collectionPanelActions = unionize({
     CREATE_COLLECTION_TAG_SUCCESS: ofType<{ tag: TagResource }>(),
     DELETE_COLLECTION_TAG: ofType<{ uuid: string }>(),
     DELETE_COLLECTION_TAG_SUCCESS: ofType<{ uuid: string }>()
-}, { tag: 'type', value: 'payload' });
+});
 
 export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
 
 export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm';
 
-export const loadCollection = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const loadCollectionPanel = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid }));
         dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() }));
-        return services.collectionService
-            .get(uuid)
-            .then(item => {
-                dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item }));
-                dispatch<any>(loadCollectionFiles(uuid));
-            });
+        const collection = await services.collectionService.get(uuid);
+        dispatch(resourcesActions.SET_RESOURCES([collection]));
+        dispatch<any>(loadCollectionFiles(collection.uuid));
+        dispatch<any>(loadCollectionTags(collection.uuid));
+        return collection;
     };
 
 export const loadCollectionTags = (uuid: string) =>
@@ -50,7 +50,6 @@ export const loadCollectionTags = (uuid: string) =>
             });
     };
 
-
 export const createCollectionTag = (data: TagProperty) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(collectionPanelActions.CREATE_COLLECTION_TAG({ data }));
index 97abfef09f2822c51912097f7643be5a68bc293b..01b4fe4fac2f489e1aebf7402d3569322745d85d 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { Dispatch } from "redux";
 import { CollectionFilesTree, CollectionFileType } from "~/models/collection-file";
 import { ServiceRepository } from "~/services/services";
@@ -22,7 +22,7 @@ export const collectionPanelFilesAction = unionize({
     TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(),
     SELECT_ALL_COLLECTION_FILES: ofType<{}>(),
     UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
-}, { tag: 'type', value: 'payload' });
+});
 
 export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
 
index 80f577e76512df7f5021ad92e22acbaa26c234ec..15ea85532d3f66a7f8a1c2a798c8c805227a4675 100644 (file)
@@ -9,8 +9,6 @@ import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service';
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
-import { projectPanelActions } from '~/store/project-panel/project-panel-action';
 
 export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
 
@@ -36,16 +34,16 @@ export const copyCollection = (resource: CollectionCopyFormDialogData) =>
             const uuidKey = 'uuid';
             delete collection[uuidKey];
             await services.collectionService.create({ ...collection, ownerUuid: resource.ownerUuid, name: resource.name });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied', hideDuration: 2000 }));
+            return collection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_COPY_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' }));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy the collection', hideDuration: 2000 }));
+                throw new Error('Could not copy the collection.');
             }
+            return ;
         }
     };
\ No newline at end of file
index d8d292c06807ab178d01ee6414be479fc1d3c2dd..1981af0d41ddae3e596e926c15cca15ac291eb9b 100644 (file)
@@ -6,8 +6,6 @@ import { Dispatch } from "redux";
 import { reset, startSubmit, stopSubmit, initialize } from 'redux-form';
 import { RootState } from '~/store/store';
 import { uploadCollectionFiles } from '~/store/collections/uploader/collection-uploader-actions';
-import { projectPanelActions } from "~/store/project-panel/project-panel-action";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { CollectionResource } from '~/models/collection';
 import { ServiceRepository } from '~/services/services';
@@ -28,28 +26,20 @@ export const openCollectionCreateDialog = (ownerUuid: string) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_CREATE_FORM_NAME, data: { ownerUuid } }));
     };
 
-export const addCollection = (data: CollectionCreateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(createCollection(data));
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: "Collection has been successfully created.",
-            hideDuration: 2000
-        }));
-    };
-
 export const createCollection = (collection: Partial<CollectionResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(COLLECTION_CREATE_FORM_NAME));
         try {
             const newCollection = await services.collectionService.create(collection);
             await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
-            dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
             dispatch(reset(COLLECTION_CREATE_FORM_NAME));
+            return newCollection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' }));
             }
+            return ;
         }
     };
\ No newline at end of file
index 6fc836f80e123064bd6f99d35a5ed3fe47291f86..dcd7b1aa8068136d3713eaf8c1f481dddac7b57e 100644 (file)
@@ -31,6 +31,7 @@ export const moveCollection = (resource: MoveToFormDialogData) =>
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been moved', hideDuration: 2000 }));
+            return collection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
@@ -39,5 +40,6 @@ export const moveCollection = (resource: MoveToFormDialogData) =>
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000 }));
             }
+            return ;
         }
     };
index 27c3bfced5dae28c5f2eb66e93cdb23088b0ba22..75e03d5066d7bd6d19422152e8881da2cb8c8db1 100644 (file)
@@ -6,7 +6,7 @@ import { Dispatch } from "redux";
 import { initialize, startSubmit, stopSubmit } from 'redux-form';
 import { RootState } from "~/store/store";
 import { collectionPanelActions } from "~/store/collection-panel/collection-panel-action";
-import { updateDetails } from "~/store/details-panel/details-panel-action";
+import { loadDetailsPanel } from "~/store/details-panel/details-panel-action";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { dataExplorerActions } from "~/store/data-explorer/data-explorer-action";
 import { snackbarActions } from "~/store/snackbar/snackbar-actions";
@@ -30,15 +30,6 @@ export const openCollectionUpdateDialog = (resource: ContextMenuResource) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME, data: {} }));
     };
 
-export const editCollection = (data: CollectionUpdateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(updateCollection(data));
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: "Collection has been successfully updated.",
-            hideDuration: 2000
-        }));
-    };
-
 export const updateCollection = (collection: Partial<CollectionResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const uuid = collection.uuid || '';
@@ -46,13 +37,13 @@ export const updateCollection = (collection: Partial<CollectionResource>) =>
         try {
             const updatedCollection = await services.collectionService.update(uuid, collection);
             dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
-            dispatch<any>(updateDetails(updatedCollection));
-            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
-        } catch(e) {
+            return updatedCollection;
+        } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' }));
             }
+            return;
         }
     };
\ No newline at end of file
index 0fa55d836cd9510d1840ffb450b9e57801a4a34b..58dcdc4cd1b31ffb05ccfcdecaedb5557d1893a3 100644 (file)
@@ -2,7 +2,7 @@
 //\r
 // SPDX-License-Identifier: AGPL-3.0\r
 \r
-import { default as unionize, ofType, UnionOf } from "unionize";\r
+import { unionize, ofType, UnionOf } from "~/common/unionize";\r
 import { Dispatch } from 'redux';\r
 import { RootState } from '~/store/store';\r
 import { ServiceRepository } from '~/services/services';\r
@@ -26,9 +26,6 @@ export const collectionUploaderActions = unionize({
     START_UPLOAD: ofType(),\r
     SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(),\r
     CLEAR_UPLOAD: ofType()\r
-}, {\r
-    tag: 'type',\r
-    value: 'payload'\r
 });\r
 \r
 export type CollectionUploaderAction = UnionOf<typeof collectionUploaderActions>;\r
index 8e5eb1e795791260474d92e0fffe6e596560081d..406239997c68d01905644d47dc5b12299fb95aeb 100644 (file)
@@ -2,15 +2,81 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from '~/common/unionize';
 import { ContextMenuPosition, ContextMenuResource } from "./context-menu-reducer";
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { getResource } from '../resources/resources';
+import { ProjectResource } from '~/models/project';
+import { UserResource } from '../../models/user';
+import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { extractUuidKind, ResourceKind } from '~/models/resource';
 
 export const contextMenuActions = unionize({
     OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
     CLOSE_CONTEXT_MENU: ofType<{}>()
-}, {
-        tag: 'type',
-        value: 'payload'
-    });
+});
 
 export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
+
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) =>
+    (dispatch: Dispatch) => {
+        event.preventDefault();
+        dispatch(
+            contextMenuActions.OPEN_CONTEXT_MENU({
+                position: { x: event.clientX, y: event.clientY },
+                resource
+            })
+        );
+    };
+
+export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const userResource = getResource<UserResource>(projectUuid)(getState().resources);
+        if (userResource) {
+            dispatch<any>(openContextMenu(event, {
+                name: '',
+                uuid: userResource.uuid,
+                kind: ContextMenuKind.ROOT_PROJECT
+            }));
+        }
+    };
+
+export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const projectResource = getResource<ProjectResource>(projectUuid)(getState().resources);
+        if (projectResource) {
+            dispatch<any>(openContextMenu(event, {
+                name: projectResource.name,
+                uuid: projectResource.uuid,
+                kind: ContextMenuKind.PROJECT
+            }));
+        }
+    };
+
+export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        if (!isSidePanelTreeCategory(id)) {
+            const kind = extractUuidKind(id);
+            if (kind === ResourceKind.USER) {
+                dispatch<any>(openRootProjectContextMenu(event, id));
+            } else if (kind === ResourceKind.PROJECT) {
+                dispatch<any>(openProjectContextMenu(event, id));
+            }
+        }
+    };
+
+export const resourceKindToContextMenuKind = (uuid: string) => {
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+            return ContextMenuKind.PROJECT;
+        case ResourceKind.COLLECTION:
+            return ContextMenuKind.COLLECTION_RESOURCE;
+        case ResourceKind.USER:
+            return ContextMenuKind.ROOT_PROJECT;
+        default:
+            return;
+    }
+};
index abb293fdcf2dd062cfdd0ffb20b6f6ed1ad5a0c0..e637043dacde193f4af1983706289f1dc4b3255c 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters";
 import { DataColumns } from "~/components/data-table/data-table";
 
@@ -17,7 +17,7 @@ export const dataExplorerActions = unionize({
     TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(),
     TOGGLE_SORT: ofType<{ id: string, columnName: string }>(),
     SET_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(),
-}, { tag: "type", value: "payload" });
+});
 
 export type DataExplorerAction = UnionOf<typeof dataExplorerActions>;
 
index 7c64020ef3b46b7e9f2ff85b4e078741a352bab4..059c078429833487c553aeb744eaf75ab8a771c7 100644 (file)
@@ -6,6 +6,8 @@ import { Dispatch, MiddlewareAPI } from "redux";
 import { RootState } from "../store";
 import { DataColumns } from "~/components/data-table/data-table";
 import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters";
+import { DataExplorer } from './data-explorer-reducer';
+import { ListArguments, ListResults } from '~/common/api/common-resource-service';
 
 export abstract class DataExplorerMiddlewareService {
     protected readonly id: string;
@@ -25,3 +27,19 @@ export abstract class DataExplorerMiddlewareService {
 
     abstract requestItems(api: MiddlewareAPI<Dispatch, RootState>): void;
 }
+
+export const getDataExplorerColumnFilters = <T, F extends DataTableFilterItem>(columns: DataColumns<T, F>, columnName: string): F[] => {
+    const column = columns.find(c => c.name === columnName);
+    return column ? column.filters.filter(f => f.selected) : [];
+};
+
+export const dataExplorerToListParams = <R>(dataExplorer: DataExplorer) => ({
+    limit: dataExplorer.rowsPerPage,
+    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+});
+
+export const listResultsToDataExplorerItemsMeta = <R>({ itemsAvailable, offset, limit }: ListResults<R>) => ({
+    itemsAvailable,
+    page: Math.floor(offset / limit),
+    rowsPerPage: limit
+});
\ No newline at end of file
index b8021fb6a0d81d12588b2efe921a0d3142c7df6c..2724a3e3465dbbac374a029f1f68c321dce2a9b1 100644 (file)
@@ -2,48 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from "unionize";
-import { Dispatch } from "redux";
-import { Resource, ResourceKind } from "~/models/resource";
-import { RootState } from "../store";
-import { ServiceRepository } from "~/services/services";
+import { unionize, ofType, UnionOf } from '~/common/unionize';
 
 export const detailsPanelActions = unionize({
     TOGGLE_DETAILS_PANEL: ofType<{}>(),
-    LOAD_DETAILS: ofType<{ uuid: string, kind: ResourceKind }>(),
-    LOAD_DETAILS_SUCCESS: ofType<{ item: Resource }>(),
-    UPDATE_DETAILS: ofType<{ item: Resource }>()
-}, { tag: 'type', value: 'payload' });
+    LOAD_DETAILS_PANEL: ofType<string>()
+});
 
 export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
 
-export const loadDetails = (uuid: string, kind: ResourceKind) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(detailsPanelActions.LOAD_DETAILS({ uuid, kind }));
-        const item = await getService(services, kind).get(uuid);
-        dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item }));
-    };
-
-export const updateDetails = (item: Resource) => 
-    async (dispatch: Dispatch, getState: () => RootState) => {
-        const currentItem = getState().detailsPanel.item;
-        if (currentItem && (currentItem.uuid === item.uuid)) {
-            dispatch(detailsPanelActions.UPDATE_DETAILS({ item }));
-            dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item }));
-        }
-    };
-
-
-const getService = (services: ServiceRepository, kind: ResourceKind) => {
-    switch (kind) {
-        case ResourceKind.PROJECT:
-            return services.projectService;
-        case ResourceKind.COLLECTION:
-            return services.collectionService;
-        default:
-            return services.projectService;
-    }
-};
+export const loadDetailsPanel = (uuid: string) => detailsPanelActions.LOAD_DETAILS_PANEL(uuid);
+
 
 
 
index f22add3d49b08810a2c5cf248ca4f90503dc0a1d..091b2fa2cf1adcfa55f19454fdae2f37d9fef7d9 100644 (file)
@@ -3,21 +3,20 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { detailsPanelActions, DetailsPanelAction } from "./details-panel-action";
-import { Resource } from "~/models/resource";
 
 export interface DetailsPanelState {
-    item: Resource | null;
+    resourceUuid: string;
     isOpened: boolean;
 }
 
 const initialState = {
-    item: null,
+    resourceUuid: '',
     isOpened: false
 };
 
 export const detailsPanelReducer = (state: DetailsPanelState = initialState, action: DetailsPanelAction) =>
     detailsPanelActions.match(action, {
         default: () => state,
-        LOAD_DETAILS_SUCCESS: ({ item }) => ({ ...state, item }),
+        LOAD_DETAILS_PANEL: resourceUuid => ({ ...state, resourceUuid }),
         TOGGLE_DETAILS_PANEL: () => ({ ...state, isOpened: !state.isOpened })
     });
index df4418f41363b32ba92e75b12c5a07547ee9b06d..22629b692f2bff6dea8c7f78ee97ea18fe0308ca 100644 (file)
@@ -2,14 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 
 export const dialogActions = unionize({
     OPEN_DIALOG: ofType<{ id: string, data: any }>(),
     CLOSE_DIALOG: ofType<{ id: string }>()
-}, {
-        tag: 'type',
-        value: 'payload'
-    });
+});
 
 export type DialogAction = UnionOf<typeof dialogActions>;
index aa1ec8d03041668f4b1d1296685cba13b23f3349..067d5ceedb90bbbdc6f947d5b6b2afe5fe2fbf00 100644 (file)
@@ -6,3 +6,5 @@ import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
 
 export const FAVORITE_PANEL_ID = "favoritePanel";
 export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID);
+
+export const loadFavoritePanel = () => favoritePanelActions.REQUEST_ITEMS();
index 1c2f062252b2101224d62b690d955849c327b698..e5857dd363e75433fb9bdb3822e738c39ea7fa48 100644 (file)
@@ -6,16 +6,18 @@ import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-mi
 import { FavoritePanelColumnNames, FavoritePanelFilter } from "~/views/favorite-panel/favorite-panel";
 import { RootState } from "../store";
 import { DataColumns } from "~/components/data-table/data-table";
-import { FavoritePanelItem, resourceToDataItem } from "~/views/favorite-panel/favorite-panel-item";
 import { ServiceRepository } from "~/services/services";
 import { SortDirection } from "~/components/data-table/data-column";
 import { FilterBuilder } from "~/common/api/filter-builder";
-import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { updateFavorites } from "../favorites/favorites-actions";
 import { favoritePanelActions } from "./favorite-panel-action";
 import { Dispatch, MiddlewareAPI } from "redux";
 import { OrderBuilder, OrderDirection } from "~/common/api/order-builder";
 import { LinkResource } from "~/models/link";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
+import { resourcesActions } from "~/store/resources/resources-actions";
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer";
 
 export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -23,53 +25,64 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
     }
 
     requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
-        const dataExplorer = api.getState().dataExplorer[this.getId()];
-        const columns = dataExplorer.columns as DataColumns<FavoritePanelItem, FavoritePanelFilter>;
-        const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
-        const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        if (!dataExplorer) {
+            api.dispatch(favoritesPanelDataExplorerIsNotSet());
+         } else {
 
-        const linkOrder = new OrderBuilder<LinkResource>();
-        const contentOrder = new OrderBuilder<GroupContentsResource>();
+            const columns = dataExplorer.columns as DataColumns<string, FavoritePanelFilter>;
+            const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
+            const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
 
-        if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) {
-            const direction = sortColumn.sortDirection === SortDirection.ASC
-                ? OrderDirection.ASC
-                : OrderDirection.DESC;
+            const linkOrder = new OrderBuilder<LinkResource>();
+            const contentOrder = new OrderBuilder<GroupContentsResource>();
 
-            linkOrder.addOrder(direction, "name");
-            contentOrder
-                .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION)
-                .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS)
-                .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT);
-        }
+            if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) {
+                const direction = sortColumn.sortDirection === SortDirection.ASC
+                    ? OrderDirection.ASC
+                    : OrderDirection.DESC;
+
+                linkOrder.addOrder(direction, "name");
+                contentOrder
+                    .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION)
+                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS)
+                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT);
+            }
 
-        this.services.favoriteService
-            .list(this.services.authService.getUuid()!, {
-                limit: dataExplorer.rowsPerPage,
-                offset: dataExplorer.page * dataExplorer.rowsPerPage,
-                linkOrder: linkOrder.getOrder(),
-                contentOrder: contentOrder.getOrder(),
-                filters: new FilterBuilder()
-                    .addIsA("headUuid", typeFilters.map(filter => filter.type))
-                    .addILike("name", dataExplorer.searchValue)
-                    .getFilters()
-            })
-            .then(response => {
-                api.dispatch(favoritePanelActions.SET_ITEMS({
-                    items: response.items.map(resourceToDataItem),
-                    itemsAvailable: response.itemsAvailable,
-                    page: Math.floor(response.offset / response.limit),
-                    rowsPerPage: response.limit
-                }));
-                api.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
-            })
-            .catch(() => {
-                api.dispatch(favoritePanelActions.SET_ITEMS({
-                    items: [],
-                    itemsAvailable: 0,
-                    page: 0,
-                    rowsPerPage: dataExplorer.rowsPerPage
-                }));
-            });
+            this.services.favoriteService
+                .list(this.services.authService.getUuid()!, {
+                    limit: dataExplorer.rowsPerPage,
+                    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                    linkOrder: linkOrder.getOrder(),
+                    contentOrder: contentOrder.getOrder(),
+                    filters: new FilterBuilder()
+                        .addIsA("headUuid", typeFilters.map(filter => filter.type))
+                        .addILike("name", dataExplorer.searchValue)
+                        .getFilters()
+                })
+                .then(response => {
+                    api.dispatch(resourcesActions.SET_RESOURCES(response.items));
+                    api.dispatch(favoritePanelActions.SET_ITEMS({
+                        items: response.items.map(resource => resource.uuid),
+                        itemsAvailable: response.itemsAvailable,
+                        page: Math.floor(response.offset / response.limit),
+                        rowsPerPage: response.limit
+                    }));
+                    api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
+                })
+                .catch(() => {
+                    api.dispatch(favoritePanelActions.SET_ITEMS({
+                        items: [],
+                        itemsAvailable: 0,
+                        page: 0,
+                        rowsPerPage: dataExplorer.rowsPerPage
+                    }));
+                });
+        }
     }
 }
+
+const favoritesPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Favorites panel is not ready.'
+    });
index 9e1b3ef1c20a3d27fdd1f46be629cfed77f85f37..e5a8e591d20d1527b0137fffc3a4c35c8cd4b1ff 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { Dispatch } from "redux";
 import { RootState } from "../store";
 import { checkFavorite } from "./favorites-reducer";
@@ -13,7 +13,7 @@ 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>;
 
@@ -40,7 +40,7 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) =>
             });
     };
 
-export const checkPresenceInFavorites = (resourceUuids: string[]) =>
+export const updateFavorites = (resourceUuids: string[]) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const userUuid = getState().auth.user!.uuid;
         dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids));
@@ -50,4 +50,3 @@ export const checkPresenceInFavorites = (resourceUuids: string[]) =>
                 dispatch(favoritesActions.UPDATE_FAVORITES(results));
             });
     };
-
index 50ec93d25e8091e2186e0eccf7a6f403d0ca90ae..1d36beeca27119aade65cc52eb82e8a4c1c4ac07 100644 (file)
@@ -2,99 +2,30 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
-import { getProjectList, projectActions } from "../project/project-action";
+import { Dispatch, compose } from 'redux';
 import { push } from "react-router-redux";
-import { TreeItemStatus } from "~/components/tree/tree";
-import { findTreeItem } from "../project/project-reducer";
-import { RootState } from "../store";
-import { ResourceKind } from "~/models/resource";
-import { projectPanelActions } from "../project-panel/project-panel-action";
+import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { getCollectionUrl } from "~/models/collection";
-import { getProjectUrl, ProjectResource } from "~/models/project";
-import { ProjectService } from "~/services/project-service/project-service";
-import { ServiceRepository } from "~/services/services";
-import { sidePanelActions } from "../side-panel/side-panel-action";
-import { SidePanelId } from "../side-panel/side-panel-reducer";
-import { getUuidObjectType, ObjectTypes } from "~/models/object-types";
-
-export const getResourceUrl = (resourceKind: ResourceKind, resourceUuid: string): string => {
-    switch (resourceKind) {
-        case ResourceKind.PROJECT: return getProjectUrl(resourceUuid);
-        case ResourceKind.COLLECTION: return getCollectionUrl(resourceUuid);
-        default:
-            return '';
-    }
-};
-
-export enum ItemMode {
-    BOTH,
-    OPEN,
-    ACTIVE
-}
-
-export const setProjectItem = (itemId: string, itemMode: ItemMode) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { projects, router } = getState();
-        const treeItem = findTreeItem(projects.items, itemId);
-
-        if (treeItem) {
-            const resourceUrl = getResourceUrl(treeItem.data.kind, treeItem.data.uuid);
-
-            if (itemMode === ItemMode.ACTIVE || itemMode === ItemMode.BOTH) {
-                if (router.location && !router.location.pathname.includes(resourceUrl)) {
-                    dispatch(push(resourceUrl));
-                }
-                dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(treeItem.data.uuid));
-            }
-
-            const promise = treeItem.status === TreeItemStatus.LOADED
-                ? Promise.resolve()
-                : dispatch<any>(getProjectList(itemId));
-
-            promise
-                .then(() => dispatch<any>(() => {
-                    if (itemMode === ItemMode.OPEN || itemMode === ItemMode.BOTH) {
-                        dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(treeItem.data.uuid));
-                    }
-                    dispatch(projectPanelActions.RESET_PAGINATION());
-                    dispatch(projectPanelActions.REQUEST_ITEMS());
-                }));
-        } else {
-            const uuid = services.authService.getUuid();
-            if (itemId === uuid) {
-                dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(uuid));
-                dispatch(projectPanelActions.RESET_PAGINATION());
-                dispatch(projectPanelActions.REQUEST_ITEMS());
-            }
+import { getProjectUrl } from "~/models/project";
+
+import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
+import { Routes } from '~/routes/routes';
+
+export const navigateTo = (uuid: string) =>
+    async (dispatch: Dispatch) => {
+        const kind = extractUuidKind(uuid);
+        if (kind === ResourceKind.PROJECT || kind === ResourceKind.USER) {
+            dispatch<any>(navigateToProject(uuid));
+        } else if (kind === ResourceKind.COLLECTION) {
+            dispatch<any>(navigateToCollection(uuid));
+        }
+        if (uuid === SidePanelTreeCategory.FAVORITES) {
+            dispatch<any>(navigateToFavorites);
         }
     };
 
-export const restoreBranch = (itemId: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = await loadProjectAncestors(itemId, services.projectService);
-        const uuids = ancestors.map(ancestor => ancestor.uuid);
-        await loadBranch(uuids, dispatch);
-        dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(SidePanelId.PROJECTS));
-        uuids.forEach(uuid => {
-            dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(uuid));
-        });
-    };
+export const navigateToFavorites = push(Routes.FAVORITES);
 
-export const loadProjectAncestors = async (uuid: string, projectService: ProjectService): Promise<Array<ProjectResource>> => {
-    if (getUuidObjectType(uuid) === ObjectTypes.USER) {
-        return [];
-    } else {
-        const currentProject = await projectService.get(uuid);
-        const ancestors = await loadProjectAncestors(currentProject.ownerUuid, projectService);
-        return [...ancestors, currentProject];
-    }
-};
+export const navigateToProject = compose(push, getProjectUrl);
 
-const loadBranch = async (uuids: string[], dispatch: Dispatch): Promise<any> => {
-    const [uuid, ...rest] = uuids;
-    if (uuid) {
-        await dispatch<any>(getProjectList(uuid));
-        return loadBranch(rest, dispatch);
-    }
-};
+export const navigateToCollection = compose(push, getCollectionUrl);
index 33cedd711734d5d6b290d872a286b07a53b844bf..6e639017dbd7de0bdd0289443efe1169694f8abe 100644 (file)
@@ -3,6 +3,20 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
-
+import { propertiesActions } from "~/store/properties/properties-actions";
+import { Dispatch } from 'redux';
+import { ServiceRepository } from "~/services/services";
+import { RootState } from '~/store/store';
+import { getProperty } from "~/store/properties/properties";
 export const PROJECT_PANEL_ID = "projectPanel";
+export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid";
 export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
+
+export const openProjectPanel = (projectUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+    };
+
+export const getProjectPanelCurrentUuid = (state: RootState) => getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+
index 663add3e2eb68c9549aac05f334cd301a156535c..da7f5b33e0d96e928f534f9491e501004fabd3f7 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service";
+import { DataExplorerMiddlewareService, getDataExplorerColumnFilters, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from '../data-explorer/data-explorer-middleware-service';
 import { ProjectPanelColumnNames, ProjectPanelFilter } from "~/views/project-panel/project-panel";
 import { RootState } from "../store";
 import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
-import { ProjectPanelItem, resourceToDataItem } from "~/views/project-panel/project-panel-item";
 import { SortDirection } from "~/components/data-table/data-column";
 import { OrderBuilder, OrderDirection } from "~/common/api/order-builder";
 import { FilterBuilder } from "~/common/api/filter-builder";
-import { GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
-import { checkPresenceInFavorites } from "../favorites/favorites-actions";
-import { projectPanelActions } from "./project-panel-action";
+import { GroupContentsResourcePrefix, GroupContentsResource } from "~/services/groups-service/groups-service";
+import { updateFavorites } from "../favorites/favorites-actions";
+import { projectPanelActions, PROJECT_PANEL_CURRENT_UUID } from './project-panel-action';
 import { Dispatch, MiddlewareAPI } from "redux";
 import { ProjectResource } from "~/models/project";
+import { updateResources } from "~/store/resources/resources-actions";
+import { getProperty } from "~/store/properties/properties";
+import { snackbarActions } from '../snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from '../data-explorer/data-explorer-reducer';
+import { ListResults } from '~/common/api/common-resource-service';
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
         super(id);
     }
 
-    requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
         const state = api.getState();
-        const dataExplorer = state.dataExplorer[this.getId()];
-        const columns = dataExplorer.columns as DataColumns<ProjectPanelItem, ProjectPanelFilter>;
-        const typeFilters = this.getColumnFilters(columns, ProjectPanelColumnNames.TYPE);
-        const statusFilters = this.getColumnFilters(columns, ProjectPanelColumnNames.STATUS);
-        const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        const projectUuid = getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+        if (!projectUuid) {
+            api.dispatch(projectPanelCurrentUuidIsNotSet());
+        } else if (!dataExplorer) {
+            api.dispatch(projectPanelDataExplorerIsNotSet());
+        } else {
+            try {
+                const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer));
+                api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
+                api.dispatch(updateResources(response.items));
+                api.dispatch(setItems(response));
+            } catch (e) {
+                api.dispatch(couldNotFetchProjectContents());
+            }
+        }
+    }
+}
 
-        const order = new OrderBuilder<ProjectResource>();
+const setItems = (listResults: ListResults<GroupContentsResource>) =>
+    projectPanelActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
 
-        if (sortColumn) {
-            const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-                ? OrderDirection.ASC
-                : OrderDirection.DESC;
+const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder(dataExplorer),
+    filters: getFilters(dataExplorer),
+});
 
-            const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
-            order
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS)
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT);
-        }
+const getFilters = (dataExplorer: DataExplorer) => {
+    const columns = dataExplorer.columns as DataColumns<string, ProjectPanelFilter>;
+    const typeFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE);
+    const statusFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.STATUS);
+    return new FilterBuilder()
+        .addIsA("uuid", typeFilters.map(f => f.type))
+        .addIn("state", statusFilters.map(f => f.type), GroupContentsResourcePrefix.PROCESS)
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
+        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
+        .getFilters();
+};
+
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
+    const order = new OrderBuilder<ProjectResource>();
+    if (sortColumn) {
+        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
 
-        this.services.groupsService
-            .contents(state.projects.currentItemId, {
-                limit: dataExplorer.rowsPerPage,
-                offset: dataExplorer.page * dataExplorer.rowsPerPage,
-                order: order.getOrder(),
-                filters: new FilterBuilder()
-                    .addIsA("uuid", typeFilters.map(f => f.type))
-                    .addIn("state", statusFilters.map(f => f.type), GroupContentsResourcePrefix.PROCESS)
-                    .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
-                    .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
-                    .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
-                    .getFilters()
-            })
-            .then(response => {
-                api.dispatch(projectPanelActions.SET_ITEMS({
-                    items: response.items.map(resourceToDataItem),
-                    itemsAvailable: response.itemsAvailable,
-                    page: Math.floor(response.offset / response.limit),
-                    rowsPerPage: response.limit
-                }));
-                api.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
-            })
-            .catch(() => {
-                api.dispatch(projectPanelActions.SET_ITEMS({
-                    items: [],
-                    itemsAvailable: 0,
-                    page: 0,
-                    rowsPerPage: dataExplorer.rowsPerPage
-                }));
-            });
+        const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
+        return order
+            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
+            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS)
+            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT)
+            .getOrder();
+    } else {
+        return order.getOrder();
     }
-}
+};
+
+const projectPanelCurrentUuidIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Project panel is not opened.'
+    });
+
+const couldNotFetchProjectContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch project contents.'
+    });
+
+const projectPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Project panel is not ready.'
+    });
diff --git a/src/store/project/project-action.ts b/src/store/project/project-action.ts
deleted file mode 100644 (file)
index ff66a9c..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-import { default as unionize, ofType, UnionOf } from "unionize";
-
-import { ProjectResource } from "~/models/project";
-import { Dispatch } from "redux";
-import { FilterBuilder } from "~/common/api/filter-builder";
-import { RootState } from "../store";
-import { checkPresenceInFavorites } from "../favorites/favorites-actions";
-import { ServiceRepository } from "~/services/services";
-
-export const projectActions = unionize({
-    REMOVE_PROJECT: ofType<string>(),
-    PROJECTS_REQUEST: ofType<string>(),
-    PROJECTS_SUCCESS: ofType<{ projects: ProjectResource[], parentItemId?: string }>(),
-    TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType<string>(),
-    TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType<string>(),
-    RESET_PROJECT_TREE_ACTIVITY: ofType<string>()
-}, {
-    tag: 'type',
-    value: 'payload'
-});
-
-export const getProjectList = (parentUuid: string = '') => 
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(projectActions.PROJECTS_REQUEST(parentUuid));
-        return services.projectService.list({
-            filters: new FilterBuilder()
-                .addEqual("ownerUuid", parentUuid)
-                .getFilters()
-        }).then(({ items: projects }) => {
-            dispatch(projectActions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
-            dispatch<any>(checkPresenceInFavorites(projects.map(project => project.uuid)));
-            return projects;
-        });
-    };
-
-export type ProjectAction = UnionOf<typeof projectActions>;
diff --git a/src/store/project/project-reducer.test.ts b/src/store/project/project-reducer.test.ts
deleted file mode 100644 (file)
index 56a6253..0000000
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { projectsReducer, getTreePath } from "./project-reducer";
-import { projectActions } from "./project-action";
-import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
-import { mockProjectResource } from "~/models/test-utils";
-
-describe('project-reducer', () => {
-
-    it('should load projects', () => {
-        const initialState = undefined;
-
-        const projects = [mockProjectResource({ uuid: "1" }), mockProjectResource({ uuid: "2" })];
-        const state = projectsReducer(initialState, projectActions.PROJECTS_SUCCESS({ projects, parentItemId: undefined }));
-        expect(state).toEqual({
-            items: [{
-                active: false,
-                open: false,
-                id: "1",
-                items: [],
-                data: mockProjectResource({ uuid: "1" }),
-                status: TreeItemStatus.INITIAL
-            }, {
-                active: false,
-                open: false,
-                id: "2",
-                items: [],
-                data: mockProjectResource({ uuid: "2" }),
-                status: TreeItemStatus.INITIAL
-            }
-            ],
-            currentItemId: ""
-        });
-    });
-
-    it('should remove activity on projects list', () => {
-        const initialState = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: true,
-                status: TreeItemStatus.PENDING
-            }],
-            currentItemId: "1"
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: false,
-                status: TreeItemStatus.PENDING
-            }],
-            currentItemId: ""
-        };
-
-        const state = projectsReducer(initialState, projectActions.RESET_PROJECT_TREE_ACTIVITY(initialState.items[0].id));
-        expect(state).toEqual(project);
-    });
-
-    it('should toggle project tree item activity', () => {
-        const initialState = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: false,
-                status: TreeItemStatus.PENDING
-            }],
-            currentItemId: "1"
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: true,
-                status: TreeItemStatus.PENDING,
-            }],
-            currentItemId: "1"
-        };
-
-        const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState.items[0].id));
-        expect(state).toEqual(project);
-    });
-
-
-    it('should close project tree item ', () => {
-        const initialState = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: false,
-                status: TreeItemStatus.PENDING,
-            }],
-            currentItemId: "1"
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: false,
-                active: false,
-                status: TreeItemStatus.PENDING,
-            }],
-            currentItemId: "1"
-        };
-
-        const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState.items[0].id));
-        expect(state).toEqual(project);
-    });
-});
-
-describe("findTreeBranch", () => {
-    const createTreeItem = (id: string, items?: Array<TreeItem<string>>): TreeItem<string> => ({
-        id,
-        items,
-        active: false,
-        data: "",
-        open: false,
-        status: TreeItemStatus.INITIAL
-    });
-
-    it("should return an array that matches path to the given item", () => {
-        const tree: Array<TreeItem<string>> = [
-            createTreeItem("1", [
-                createTreeItem("1.1", [
-                    createTreeItem("1.1.1"),
-                    createTreeItem("1.1.2")
-                ])
-            ]),
-            createTreeItem("2", [
-                createTreeItem("2.1", [
-                    createTreeItem("2.1.1"),
-                    createTreeItem("2.1.2")
-                ])
-            ])
-        ];
-        const branch = getTreePath(tree, "2.1.1");
-        expect(branch.map(item => item.id)).toEqual(["2", "2.1", "2.1.1"]);
-    });
-
-    it("should return empty array if item is not found", () => {
-        const tree: Array<TreeItem<string>> = [
-            createTreeItem("1", [
-                createTreeItem("1.1", [
-                    createTreeItem("1.1.1"),
-                    createTreeItem("1.1.2")
-                ])
-            ]),
-            createTreeItem("2", [
-                createTreeItem("2.1", [
-                    createTreeItem("2.1.1"),
-                    createTreeItem("2.1.2")
-                ])
-            ])
-        ];
-        expect(getTreePath(tree, "3")).toHaveLength(0);
-    });
-
-});
diff --git a/src/store/project/project-reducer.ts b/src/store/project/project-reducer.ts
deleted file mode 100644 (file)
index 452f6be..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as _ from "lodash";
-
-import { projectActions, ProjectAction } from "./project-action";
-import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
-import { ProjectResource } from "~/models/project";
-
-export type ProjectState = {
-    items: Array<TreeItem<ProjectResource>>,
-    currentItemId: string
-};
-
-interface ProjectUpdater {
-    opened: boolean;
-    uuid: string;
-}
-
-export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
-    let item;
-    for (const t of tree) {
-        item = t.id === itemId
-            ? t
-            : findTreeItem(t.items ? t.items : [], itemId);
-        if (item) {
-            break;
-        }
-    }
-    return item;
-}
-
-export function getActiveTreeItem<T>(tree: Array<TreeItem<T>>): TreeItem<T> | undefined {
-    let item;
-    for (const t of tree) {
-        item = t.active
-            ? t
-            : getActiveTreeItem(t.items ? t.items : []);
-        if (item) {
-            break;
-        }
-    }
-    return item;
-}
-
-export function getTreePath<T>(tree: Array<TreeItem<T>>, itemId: string): Array<TreeItem<T>> {
-    for (const item of tree) {
-        if (item.id === itemId) {
-            return [item];
-        } else {
-            const branch = getTreePath(item.items || [], itemId);
-            if (branch.length > 0) {
-                return [item, ...branch];
-            }
-        }
-    }
-    return [];
-}
-
-function resetTreeActivity<T>(tree: Array<TreeItem<T>>) {
-    for (const t of tree) {
-        t.active = false;
-        resetTreeActivity(t.items ? t.items : []);
-    }
-}
-
-function updateProjectTree(tree: Array<TreeItem<ProjectResource>>, projects: ProjectResource[], parentItemId?: string): Array<TreeItem<ProjectResource>> {
-    let treeItem;
-    if (parentItemId) {
-        treeItem = findTreeItem(tree, parentItemId);
-        if (treeItem) {
-            treeItem.status = TreeItemStatus.LOADED;
-        }
-    }
-    const items = projects.map(p => ({
-        id: p.uuid,
-        open: false,
-        active: false,
-        status: TreeItemStatus.INITIAL,
-        data: p,
-        items: []
-    } as TreeItem<ProjectResource>));
-
-    if (treeItem) {
-        treeItem.items = items;
-        return tree;
-    }
-
-    return items;
-}
-
-const initialState: ProjectState = {
-    items: [],
-    currentItemId: ""
-};
-
-
-export const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => {
-    return projectActions.match(action, {
-        REMOVE_PROJECT: () => state,
-        PROJECTS_REQUEST: itemId => {
-            const items = _.cloneDeep(state.items);
-            const item = findTreeItem(items, itemId);
-            if (item) {
-                item.status = TreeItemStatus.PENDING;
-                state.items = items;
-            }
-            return { ...state, items };
-        },
-        PROJECTS_SUCCESS: ({ projects, parentItemId }) => {
-            const items = _.cloneDeep(state.items);
-            return {
-                ...state,
-                items: updateProjectTree(items, projects, parentItemId)
-            };
-        },
-        TOGGLE_PROJECT_TREE_ITEM_OPEN: itemId => {
-            const items = _.cloneDeep(state.items);
-            const item = findTreeItem(items, itemId);
-            if (item) {
-                item.open = !item.open;
-            }
-            return {
-                ...state,
-                items,
-                currentItemId: itemId
-            };
-        },
-        TOGGLE_PROJECT_TREE_ITEM_ACTIVE: itemId => {
-            const items = _.cloneDeep(state.items);
-            resetTreeActivity(items);
-            const item = findTreeItem(items, itemId);
-            if (item) {
-                item.active = true;
-            }
-            return {
-                ...state,
-                items,
-                currentItemId: itemId
-            };
-        },
-        RESET_PROJECT_TREE_ACTIVITY: () => {
-            const items = _.cloneDeep(state.items);
-            resetTreeActivity(items);
-            return {
-                ...state,
-                items,
-                currentItemId: ""
-            };
-        },
-        default: () => state
-    });
-};
index d1bc827ffb02d3b0a8255b29b4884d002eb256da..76f20590763ff8c0791117faedeee73614ce7aa3 100644 (file)
@@ -5,15 +5,11 @@
 import { Dispatch } from "redux";
 import { reset, startSubmit, stopSubmit, initialize } from 'redux-form';
 import { RootState } from '~/store/store';
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
 import { dialogActions } from "~/store/dialog/dialog-actions";
-import { projectPanelActions } from '~/store/project-panel/project-panel-action';
-import { getProjectList } from '~/store/project/project-action';
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service';
 import { ProjectResource } from '~/models/project';
 import { ServiceRepository } from '~/services/services';
 
-
 export interface ProjectCreateFormDialogData {
     ownerUuid: string;
     name: string;
@@ -28,29 +24,19 @@ export const openProjectCreateDialog = (ownerUuid: string) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_CREATE_FORM_NAME, data: {} }));
     };
 
-export const addProject = (data: ProjectCreateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(createProject(data));
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: "Project has been successfully created.",
-            hideDuration: 2000
-        }));
-    };
-
-
-const createProject = (project: Partial<ProjectResource>) =>
+export const createProject = (project: Partial<ProjectResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(PROJECT_CREATE_FORM_NAME));
         try {
             const newProject = await services.projectService.create(project);
-            dispatch<any>(getProjectList(newProject.ownerUuid));
-            dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
             dispatch(reset(PROJECT_CREATE_FORM_NAME));
+            return newProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' }));
             }
+            return undefined;
         }
-    };
\ No newline at end of file
+    };
index 52d17ac814c43904de5f2b4347b3ad5b224ee961..6e24314ccc6f00a75bbcbb06629defb9a716dff2 100644 (file)
@@ -8,12 +8,8 @@ import { startSubmit, stopSubmit, initialize } from 'redux-form';
 import { ServiceRepository } from '~/services/services';
 import { RootState } from '~/store/store';
 import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
-import { projectPanelActions } from '~/store/project-panel/project-panel-action';
-import { getProjectList } from '~/store/project/project-action';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
-import { findTreeItem } from '~/store/project/project-reducer';
 
 export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName';
 
@@ -29,15 +25,9 @@ export const moveProject = (resource: MoveToFormDialogData) =>
         dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
         try {
             const project = await services.projectService.get(resource.uuid);
-            await services.projectService.update(resource.uuid, { ...project, ownerUuid: resource.ownerUuid });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
-            dispatch<any>(getProjectList(project.ownerUuid));
-            const { projects } = getState();
-            if (findTreeItem(projects.items, resource.ownerUuid)) {
-                dispatch<any>(getProjectList(resource.ownerUuid));
-            }
+            const newProject = await services.projectService.update(resource.uuid, { ...project, ownerUuid: resource.ownerUuid });
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000 }));
+            return newProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
@@ -46,7 +36,8 @@ export const moveProject = (resource: MoveToFormDialogData) =>
                 dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' }));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the project.', hideDuration: 2000 }));
+                throw new Error('Could not move the project.');
             }
+            return;
         }
     };
index e674d2960d7685572e143520d2ecd8facde066e9..39b97b24f782e7d0ac1e99a46b441f8828352fb8 100644 (file)
@@ -5,15 +5,11 @@
 import { Dispatch } from "redux";
 import { initialize, startSubmit, stopSubmit } from 'redux-form';
 import { RootState } from "~/store/store";
-import { updateDetails } from "~/store/details-panel/details-panel-action";
 import { dialogActions } from "~/store/dialog/dialog-actions";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
 import { ContextMenuResource } from '~/store/context-menu/context-menu-reducer';
 import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
 import { ServiceRepository } from "~/services/services";
 import { ProjectResource } from '~/models/project';
-import { getProjectList } from '~/store/project/project-action';
-import { projectPanelActions } from '~/store/project-panel/project-panel-action';
 
 export interface ProjectUpdateFormDialogData {
     uuid: string;
@@ -29,29 +25,19 @@ export const openProjectUpdateDialog = (resource: ContextMenuResource) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {} }));
     };
 
-export const editProject = (data: ProjectUpdateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(updateProject(data));
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: "Project has been successfully updated.",
-            hideDuration: 2000
-        }));
-    };
-
 export const updateProject = (project: Partial<ProjectResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const uuid = project.uuid || '';
         dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
         try {
             const updatedProject = await services.projectService.update(uuid, project);
-            dispatch(projectPanelActions.REQUEST_ITEMS());
-            dispatch<any>(getProjectList(updatedProject.ownerUuid));
-            dispatch<any>(updateDetails(updatedProject));
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+            return updatedProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' }));
             }
+            return ;
         }
     };
\ No newline at end of file
diff --git a/src/store/properties/properties-actions.ts b/src/store/properties/properties-actions.ts
new file mode 100644 (file)
index 0000000..8647a9c
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from '~/common/unionize';
+
+export const propertiesActions = unionize({
+    SET_PROPERTY: ofType<{ key: string, value: any }>(),
+    DELETE_PROPERTY: ofType<string>(),
+});
+
+export type PropertiesAction = UnionOf<typeof propertiesActions>;
diff --git a/src/store/properties/properties-reducer.ts b/src/store/properties/properties-reducer.ts
new file mode 100644 (file)
index 0000000..27fdf85
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, setProperty, deleteProperty } from './properties';
+import { PropertiesAction, propertiesActions } from './properties-actions';
+
+
+export const propertiesReducer = (state: PropertiesState = {}, action: PropertiesAction) =>
+    propertiesActions.match(action, {
+        SET_PROPERTY: ({ key, value }) => setProperty(key, value)(state),
+        DELETE_PROPERTY: key => deleteProperty(key)(state),
+        default: () => state,
+    });
\ No newline at end of file
diff --git a/src/store/properties/properties.ts b/src/store/properties/properties.ts
new file mode 100644 (file)
index 0000000..bee5b19
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type PropertiesState = { [key: string]: any };
+
+export const getProperty = <T>(id: string) =>
+    (state: PropertiesState): T | undefined =>
+        state[id];
+
+export const setProperty = <T>(id: string, data: T) =>
+    (state: PropertiesState) => ({
+        ...state,
+        [id]: data
+    });
+
+export const deleteProperty = (id: string) =>
+    (state: PropertiesState) => {
+        const newState = { ...state };
+        delete newState[id];
+        return newState;
+    };
+
diff --git a/src/store/resources/resources-actions.ts b/src/store/resources/resources-actions.ts
new file mode 100644 (file)
index 0000000..0034e7a
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from '~/common/unionize';
+import { Resource, extractUuidKind } from '~/models/resource';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from '~/services/services';
+import { getResourceService } from '~/services/services';
+
+export const resourcesActions = unionize({
+    SET_RESOURCES: ofType<Resource[]>(),
+    DELETE_RESOURCES: ofType<string[]>()
+});
+
+export type ResourcesAction = UnionOf<typeof resourcesActions>;
+
+export const updateResources = (resources: Resource[]) => resourcesActions.SET_RESOURCES(resources);
+
+export const loadResource = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const kind = extractUuidKind(uuid);
+        const service = getResourceService(kind)(services);
+        if (service) {
+            const resource = await service.get(uuid);
+            dispatch<any>(updateResources([resource]));
+            return resource;
+        }
+        return undefined;
+    };
\ No newline at end of file
diff --git a/src/store/resources/resources-reducer.ts b/src/store/resources/resources-reducer.ts
new file mode 100644 (file)
index 0000000..22108e0
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourcesState, setResource, deleteResource } from './resources';
+import { ResourcesAction, resourcesActions } from './resources-actions';
+
+export const resourcesReducer = (state: ResourcesState = {}, action: ResourcesAction) =>
+    resourcesActions.match(action, {
+        SET_RESOURCES: resources => resources.reduce((state, resource) => setResource(resource.uuid, resource)(state), state),
+        DELETE_RESOURCES: ids => ids.reduce((state, id) => deleteResource(id)(state), state),
+        default: () => state,
+    });
\ No newline at end of file
diff --git a/src/store/resources/resources.ts b/src/store/resources/resources.ts
new file mode 100644 (file)
index 0000000..add4efe
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "~/models/resource";
+import { ResourceKind } from '../../models/resource';
+
+export type ResourcesState = { [key: string]: Resource };
+
+export const getResource = <T extends Resource = Resource>(id: string) =>
+    (state: ResourcesState): T | undefined =>
+        state[id] as T;
+
+export const setResource = <T extends Resource>(id: string, data: T) =>
+    (state: ResourcesState) => ({
+        ...state,
+        [id]: data
+    });
+
+export const deleteResource = (id: string) =>
+    (state: ResourcesState) => {
+        const newState = {...state};
+        delete newState[id];
+        return newState;
+    };
+
+export const filterResources = (filter: (resource: Resource) => boolean) =>
+    (state: ResourcesState) =>
+        Object
+            .keys(state)
+            .map(id => getResource(id)(state))
+            .filter(filter);
+
+export const filterResourcesByKind = (kind: ResourceKind) =>
+    (state: ResourcesState) =>
+        filterResources(resource => resource.kind === kind)(state);
+
diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts
new file mode 100644 (file)
index 0000000..8fbc375
--- /dev/null
@@ -0,0 +1,162 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
+import { createTreePickerNode, TreePickerNode } from '~/store/tree-picker/tree-picker';
+import { RootState } from '../store';
+import { ServiceRepository } from '~/services/services';
+import { FilterBuilder } from '~/common/api/filter-builder';
+import { resourcesActions } from '../resources/resources-actions';
+import { getTreePicker, TreePicker } from '../tree-picker/tree-picker';
+import { TreeItemStatus } from "~/components/tree/tree";
+import { getNodeAncestors, getNodeValue, getNodeAncestorsIds, getNode } from '~/models/tree';
+import { ProjectResource } from '~/models/project';
+
+export enum SidePanelTreeCategory {
+    PROJECTS = 'Projects',
+    SHARED_WITH_ME = 'Shared with me',
+    WORKFLOWS = 'Workflows',
+    RECENT_OPEN = 'Recent open',
+    FAVORITES = 'Favorites',
+    TRASH = 'Trash'
+}
+
+export const SIDE_PANEL_TREE = 'sidePanelTree';
+
+export const getSidePanelTree = (treePicker: TreePicker) =>
+    getTreePicker<ProjectResource | string>(SIDE_PANEL_TREE)(treePicker);
+
+export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker): Array<TreePickerNode<ProjectResource | string>> => {
+    const tree = getSidePanelTree(treePicker);
+    if (tree) {
+        const ancestors = getNodeAncestors(uuid)(tree).map(node => node.value);
+        const node = getNodeValue(uuid)(tree);
+        if (node) {
+            return [...ancestors, node];
+        }
+    }
+    return [];
+};
+
+const SIDE_PANEL_CATEGORIES = [
+    SidePanelTreeCategory.SHARED_WITH_ME,
+    SidePanelTreeCategory.WORKFLOWS,
+    SidePanelTreeCategory.RECENT_OPEN,
+    SidePanelTreeCategory.FAVORITES,
+    SidePanelTreeCategory.TRASH,
+];
+
+export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id);
+
+export const initSidePanelTree = () =>
+    (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => {
+        const rootProjectUuid = authService.getUuid() || '';
+        const nodes = SIDE_PANEL_CATEGORIES.map(nodeId => createTreePickerNode({ nodeId, value: nodeId }));
+        const projectsNode = createTreePickerNode({ nodeId: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            nodeId: '',
+            pickerId: SIDE_PANEL_TREE,
+            nodes: [projectsNode, ...nodes]
+        }));
+        SIDE_PANEL_CATEGORIES.forEach(category => {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+                nodeId: category,
+                pickerId: SIDE_PANEL_TREE,
+                nodes: []
+            }));
+        });
+    };
+
+export const loadSidePanelTreeProjects = (projectUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const treePicker = getTreePicker(SIDE_PANEL_TREE)(getState().treePicker);
+        const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
+        if (node || projectUuid === '') {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: projectUuid, pickerId: SIDE_PANEL_TREE }));
+            const params = {
+                filters: new FilterBuilder()
+                    .addEqual('ownerUuid', projectUuid)
+                    .getFilters()
+            };
+            const { items } = await services.projectService.list(params);
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+                nodeId: projectUuid,
+                pickerId: SIDE_PANEL_TREE,
+                nodes: items.map(item => createTreePickerNode({ nodeId: item.uuid, value: item })),
+            }));
+            dispatch(resourcesActions.SET_RESOURCES(items));
+        }
+    };
+
+export const activateSidePanelTreeItem = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const node = getSidePanelTreeNode(nodeId)(getState().treePicker);
+        if (node && !node.selected) {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE }));
+        }
+        if (!isSidePanelTreeCategory(nodeId)) {
+            await dispatch<any>(activateSidePanelTreeProject(nodeId));
+        }
+    };
+
+export const activateSidePanelTreeProject = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { treePicker } = getState();
+        const node = getSidePanelTreeNode(nodeId)(treePicker);
+        if (node && node.status !== TreeItemStatus.LOADED) {
+            await dispatch<any>(loadSidePanelTreeProjects(nodeId));
+        } else if (node === undefined) {
+            await dispatch<any>(activateSidePanelTreeBranch(nodeId));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            nodeIds: getSidePanelTreeNodeAncestorsIds(nodeId)(treePicker),
+            pickerId: SIDE_PANEL_TREE
+        }));
+        dispatch<any>(expandSidePanelTreeItem(nodeId));
+    };
+
+export const activateSidePanelTreeBranch = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const ancestors = await services.ancestorsService.ancestors(nodeId, services.authService.getUuid() || '');
+        for (const ancestor of ancestors) {
+            await dispatch<any>(loadSidePanelTreeProjects(ancestor.uuid));
+        }
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
+            nodeIds: ancestors.map(ancestor => ancestor.uuid),
+            pickerId: SIDE_PANEL_TREE
+        }));
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE }));
+    };
+
+export const toggleSidePanelTreeItemCollapse = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSidePanelTreeNode(nodeId)(getState().treePicker);
+        if (node && node.status === TreeItemStatus.INITIAL) {
+            await dispatch<any>(loadSidePanelTreeProjects(node.nodeId));
+        }
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId: SIDE_PANEL_TREE }));
+    };
+
+export const expandSidePanelTreeItem = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const node = getSidePanelTreeNode(nodeId)(getState().treePicker);
+        if (node && node.collapsed) {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId: SIDE_PANEL_TREE }));
+        }
+    };
+
+const getSidePanelTreeNode = (nodeId: string) => (treePicker: TreePicker) => {
+    const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker);
+    return sidePanelTree
+        ? getNodeValue(nodeId)(sidePanelTree)
+        : undefined;
+};
+
+const getSidePanelTreeNodeAncestorsIds = (nodeId: string) => (treePicker: TreePicker) => {
+    const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker);
+    return sidePanelTree
+        ? getNodeAncestorsIds(nodeId)(sidePanelTree)
+        : [];
+};
index ecea3535e35040fdbb8095830ea60e21bacff95e..8c7ef4a7a1ff7ff6d8d3f1023e8d7d93cefd6f08 100644 (file)
@@ -2,14 +2,31 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
-import { SidePanelId } from "~/store/side-panel/side-panel-reducer";
+import { Dispatch } from 'redux';
+import { isSidePanelTreeCategory, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { navigateToFavorites, navigateTo } from '../navigation/navigation-action';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
 
-export const sidePanelActions = unionize({
-    TOGGLE_SIDE_PANEL_ITEM_OPEN: ofType<SidePanelId>()
-}, {
-    tag: 'type',
-    value: 'payload'
-});
+export const navigateFromSidePanel = (id: string) =>
+    (dispatch: Dispatch) => {
+        if (isSidePanelTreeCategory(id)) {
+            dispatch<any>(getSidePanelTreeCategoryAction(id));
+        } else {
+            dispatch<any>(navigateTo(id));
+        }
+    };
 
-export type SidePanelAction = UnionOf<typeof sidePanelActions>;
+const getSidePanelTreeCategoryAction = (id: string) => {
+    switch (id) {
+        case SidePanelTreeCategory.FAVORITES:
+            return navigateToFavorites;
+        default:
+            return sidePanelTreeCategoryNotAvailable(id);
+    }
+};
+
+const sidePanelTreeCategoryNotAvailable = (id: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `${id} not available`,
+        hideDuration: 3000,
+    });
\ No newline at end of file
diff --git a/src/store/side-panel/side-panel-reducer.test.ts b/src/store/side-panel/side-panel-reducer.test.ts
deleted file mode 100644 (file)
index a76e33a..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { sidePanelReducer } from "./side-panel-reducer";
-import { sidePanelActions } from "./side-panel-action";
-import { ProjectsIcon } from "~/components/icon/icon";
-
-describe('side-panel-reducer', () => {
-    it('should open side-panel item', () => {
-        const initialState = [
-            {
-                id: "1",
-                name: "Projects",
-                url: "/projects",
-                icon: ProjectsIcon,
-                open: false
-            }
-        ];
-        const project = [
-            {
-                id: "1",
-                name: "Projects",
-                icon: ProjectsIcon,
-                open: true,
-                url: "/projects"
-            }
-        ];
-
-        const state = sidePanelReducer(initialState, sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(initialState[0].id));
-        expect(state).toEqual(project);
-    });
-});
diff --git a/src/store/side-panel/side-panel-reducer.ts b/src/store/side-panel/side-panel-reducer.ts
deleted file mode 100644 (file)
index db1cbe5..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { sidePanelActions, SidePanelAction } from './side-panel-action';
-import { SidePanelItem } from '~/components/side-panel/side-panel';
-import { ProjectsIcon, ShareMeIcon, WorkflowIcon, RecentIcon, FavoriteIcon, TrashIcon } from "~/components/icon/icon";
-import { Dispatch } from "redux";
-import { push } from "react-router-redux";
-import { favoritePanelActions } from "../favorite-panel/favorite-panel-action";
-import { projectPanelActions } from "../project-panel/project-panel-action";
-import { projectActions } from "../project/project-action";
-import { getProjectUrl } from "../../models/project";
-import { columns as projectPanelColumns } from "../../views/project-panel/project-panel";
-import { columns as favoritePanelColumns } from "../../views/favorite-panel/favorite-panel";
-
-export type SidePanelState = SidePanelItem[];
-
-export const sidePanelReducer = (state: SidePanelState = sidePanelItems, action: SidePanelAction) => {
-    return sidePanelActions.match(action, {
-        TOGGLE_SIDE_PANEL_ITEM_OPEN: itemId =>
-            state.map(it => ({...it, open: itemId === it.id && it.open === false})),
-        default: () => state
-    });
-};
-
-export enum SidePanelId {
-    PROJECTS = "Projects",
-    SHARED_WITH_ME = "SharedWithMe",
-    WORKFLOWS = "Workflows",
-    RECENT_OPEN = "RecentOpen",
-    FAVORITES = "Favourites",
-    TRASH = "Trash"
-}
-
-export const sidePanelItems = [
-    {
-        id: SidePanelId.PROJECTS,
-        name: "Projects",
-        url: "/projects",
-        icon: ProjectsIcon,
-        open: false,
-        active: false,
-        margin: true,
-        openAble: true,
-        activeAction: (dispatch: Dispatch, uuid: string) => {
-            dispatch(push(getProjectUrl(uuid)));
-            dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(uuid));
-            dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
-            dispatch(projectPanelActions.RESET_PAGINATION());
-            dispatch(projectPanelActions.REQUEST_ITEMS());
-        }
-    },
-    {
-        id: SidePanelId.SHARED_WITH_ME,
-        name: "Shared with me",
-        url: "/shared",
-        icon: ShareMeIcon,
-        active: false,
-        activeAction: (dispatch: Dispatch) => {
-            dispatch(push("/shared"));
-        }
-    },
-    {
-        id: SidePanelId.WORKFLOWS,
-        name: "Workflows",
-        url: "/workflows",
-        icon: WorkflowIcon,
-        active: false,
-        activeAction: (dispatch: Dispatch) => {
-            dispatch(push("/workflows"));
-        }
-    },
-    {
-        id: SidePanelId.RECENT_OPEN,
-        name: "Recent open",
-        url: "/recent",
-        icon: RecentIcon,
-        active: false,
-        activeAction: (dispatch: Dispatch) => {
-            dispatch(push("/recent"));
-        }
-    },
-    {
-        id: SidePanelId.FAVORITES,
-        name: "Favorites",
-        url: "/favorites",
-        icon: FavoriteIcon,
-        active: false,
-        activeAction: (dispatch: Dispatch) => {
-            dispatch(push("/favorites"));
-            dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
-            dispatch(favoritePanelActions.RESET_PAGINATION());
-            dispatch(favoritePanelActions.REQUEST_ITEMS());
-        }
-    },
-    {
-        id: SidePanelId.TRASH,
-        name: "Trash",
-        url: "/trash",
-        icon: TrashIcon,
-        active: false,
-        activeAction: (dispatch: Dispatch) => {
-            dispatch(push("/trash"));
-        }
-    }
-];
index 2f6175ad68ac9040028ccc77ed28c918426d7cc7..55d9f3a8651b86afecc516aa48dc1af0abc412e1 100644 (file)
@@ -2,11 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 
 export const snackbarActions = unionize({
     OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number}>(),
     CLOSE_SNACKBAR: ofType<{}>()
-}, { tag: 'type', value: 'payload' });
+});
 
 export type SnackbarAction = UnionOf<typeof snackbarActions>;
index 403c19f0071d4eac0949becc10fbb389082c40bb..fc2f4a1964e27627ff5d02e63150713bbe78eb3b 100644 (file)
@@ -20,7 +20,7 @@ const initialState: SnackbarState = {
 
 export const snackbarReducer = (state = initialState, action: SnackbarAction) => {
     return snackbarActions.match(action, {
-        OPEN_SNACKBAR: data => ({ ...data, open: true }),
+        OPEN_SNACKBAR: data => ({ ...initialState, ...data, open: true }),
         CLOSE_SNACKBAR: () => initialState,
         default: () => state,
     });
index a4bf9d6e3b9692d00e92bfcdc59ec93fa66c89bb..abfe187c93252f73a0612f1daea063debb837096 100644 (file)
@@ -3,75 +3,43 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux';
-import { routerMiddleware, routerReducer, RouterState } from "react-router-redux";
+import { routerMiddleware, routerReducer } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
 
-import { projectsReducer, ProjectState } from "./project/project-reducer";
-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 { detailsPanelReducer, DetailsPanelState } from './details-panel/details-panel-reducer';
-import { contextMenuReducer, ContextMenuState } from './context-menu/context-menu-reducer';
+import { authReducer } from "./auth/auth-reducer";
+import { dataExplorerReducer } from './data-explorer/data-explorer-reducer';
+import { detailsPanelReducer } from './details-panel/details-panel-reducer';
+import { contextMenuReducer } from './context-menu/context-menu-reducer';
 import { reducer as formReducer } from 'redux-form';
-import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
-import { snackbarReducer, SnackbarState } from './snackbar/snackbar-reducer';
-import { CollectionPanelFilesState } from './collection-panel/collection-panel-files/collection-panel-files-state';
+import { favoritesReducer } from './favorites/favorites-reducer';
+import { snackbarReducer } from './snackbar/snackbar-reducer';
 import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer';
 import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
 import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
 import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
 import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
 import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
-import { CollectionPanelState, collectionPanelReducer } from './collection-panel/collection-panel-reducer';
-import { DialogState, dialogReducer } from './dialog/dialog-reducer';
-import { CollectionsState, collectionsReducer } from './collections/collections-reducer';
+import { collectionPanelReducer } from './collection-panel/collection-panel-reducer';
+import { dialogReducer } from './dialog/dialog-reducer';
+import { collectionsReducer } from './collections/collections-reducer';
 import { ServiceRepository } from "~/services/services";
 import { treePickerReducer } from './tree-picker/tree-picker-reducer';
-import { TreePicker } from './tree-picker/tree-picker';
+import { resourcesReducer } from '~/store/resources/resources-reducer';
+import { propertiesReducer } from './properties/properties-reducer';
+import { RootState } from './store';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
         window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
     compose;
 
-export interface RootState {
-    auth: AuthState;
-    projects: ProjectState;
-    collections: CollectionsState;
-    router: RouterState;
-    dataExplorer: DataExplorerState;
-    sidePanel: SidePanelState;
-    collectionPanel: CollectionPanelState;
-    detailsPanel: DetailsPanelState;
-    contextMenu: ContextMenuState;
-    favorites: FavoritesState;
-    snackbar: SnackbarState;
-    collectionPanelFiles: CollectionPanelFilesState;
-    dialog: DialogState;
-    treePicker: TreePicker;
-}
+export type RootState = ReturnType<ReturnType<typeof createRootReducer>>;
 
 export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
 
 export function configureStore(history: History, services: ServiceRepository): RootStore {
-    const rootReducer = combineReducers({
-        auth: authReducer(services),
-        projects: projectsReducer,
-        collections: collectionsReducer,
-        router: routerReducer,
-        dataExplorer: dataExplorerReducer,
-        sidePanel: sidePanelReducer,
-        collectionPanel: collectionPanelReducer,
-        detailsPanel: detailsPanelReducer,
-        contextMenu: contextMenuReducer,
-        form: formReducer,
-        favorites: favoritesReducer,
-        snackbar: snackbarReducer,
-        collectionPanelFiles: collectionPanelFilesReducer,
-        dialog: dialogReducer,
-        treePicker: treePickerReducer,
-    });
+    const rootReducer = createRootReducer(services);
 
     const projectPanelMiddleware = dataExplorerMiddleware(
         new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)
@@ -89,3 +57,21 @@ export function configureStore(history: History, services: ServiceRepository): R
     const enhancer = composeEnhancers(applyMiddleware(...middlewares));
     return createStore(rootReducer, enhancer);
 }
+
+const createRootReducer = (services: ServiceRepository) => combineReducers({
+    auth: authReducer(services),
+    collections: collectionsReducer,
+    router: routerReducer,
+    dataExplorer: dataExplorerReducer,
+    collectionPanel: collectionPanelReducer,
+    detailsPanel: detailsPanelReducer,
+    contextMenu: contextMenuReducer,
+    form: formReducer,
+    favorites: favoritesReducer,
+    snackbar: snackbarReducer,
+    collectionPanelFiles: collectionPanelFilesReducer,
+    dialog: dialogReducer,
+    treePicker: treePickerReducer,
+    resources: resourcesReducer,
+    properties: propertiesReducer,
+});
index 34f1303717e0097a723c4851af8e94481f4f213d..5b04389af6850888a3bad224dc11fc507005d5e3 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { default as unionize, ofType, UnionOf } from "unionize";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
 
 import { TreePickerNode } from "./tree-picker";
 
@@ -11,10 +11,8 @@ export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ nodeId: string, nodes: Array<TreePickerNode>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ nodeId: string, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ nodeId: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODES: ofType<{ nodeIds: string[], pickerId: string }>(),
     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
-}, {
-        tag: 'type',
-        value: 'payload'
-    });
+});
 
 export type TreePickerAction = UnionOf<typeof treePickerActions>;
index e7173d22ac73832403cb486c345f41b6f106946b..b0d9bc94caa9843d6774b141ea8687737f773240 100644 (file)
@@ -7,6 +7,7 @@ import { TreePicker, TreePickerNode } from "./tree-picker";
 import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
 import { TreeItemStatus } from "~/components/tree/tree";
 import { compose } from "redux";
+import { getNode } from '../../models/tree';
 
 export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
     treePickerActions.match(action, {
@@ -18,8 +19,10 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
             updateOrCreatePicker(state, pickerId, setNodeValueWith(toggleCollapse)(nodeId)),
         TOGGLE_TREE_PICKER_NODE_SELECT: ({ nodeId, pickerId }) =>
             updateOrCreatePicker(state, pickerId, mapTreeValues(toggleSelect(nodeId))),
-        RESET_TREE_PICKER: ({ pickerId }) => 
+        RESET_TREE_PICKER: ({ pickerId }) =>
             updateOrCreatePicker(state, pickerId, createTree),
+        EXPAND_TREE_PICKER_NODES: ({ pickerId, nodeIds }) =>
+            updateOrCreatePicker(state, pickerId, mapTreeValues(expand(nodeIds))),
         default: () => state
     });
 
@@ -29,6 +32,11 @@ const updateOrCreatePicker = (state: TreePicker, pickerId: string, func: (value:
     return { ...state, [pickerId]: updatedPicker };
 };
 
+const expand = (ids: string[]) => (node: TreePickerNode): TreePickerNode =>
+    ids.some(id => id === node.nodeId)
+        ? { ...node, collapsed: false }
+        : node;
+
 const setPending = (value: TreePickerNode): TreePickerNode =>
     ({ ...value, status: TreeItemStatus.PENDING });
 
@@ -43,11 +51,19 @@ const toggleSelect = (nodeId: string) => (value: TreePickerNode): TreePickerNode
         ? ({ ...value, selected: !value.selected })
         : ({ ...value, selected: false });
 
-const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: Tree<TreePickerNode>) =>
-    nodes.reduce((tree, node) =>
-        setNode(
-            createTreeNode(parent)(node)
-        )(tree), state);
+const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: Tree<TreePickerNode>) => {
+    const parentNode = getNode(parent)(state);
+    let newState = state;
+    if (parentNode) {
+        newState = setNode({ ...parentNode, children: [] })(state);
+    }
+    return nodes.reduce((tree, node) => {
+        const oldNode = getNode(node.nodeId)(state) || { value: {} };
+        const newNode = createTreeNode(parent)(node);
+        const value = { ...oldNode.value, ...newNode.value };
+        return setNode({ ...newNode, value })(tree);
+    }, newState);
+};
 
 const createTreeNode = (parent: string) => (node: TreePickerNode): TreeNode<TreePickerNode> => ({
     children: [],
index c815ad4f900468ed74f2bd0b33d062e5e377f219..259a4b8d53de78e1b7d9992ae85b4d69d5fe40ca 100644 (file)
@@ -7,9 +7,9 @@ import { TreeItemStatus } from "~/components/tree/tree";
 
 export type TreePicker = { [key: string]: Tree<TreePickerNode> };
 
-export interface TreePickerNode {
+export interface TreePickerNode<Value = any> {
     nodeId: string;
-    value: any;
+    value: Value;
     selected: boolean;
     collapsed: boolean;
     status: TreeItemStatus;
@@ -21,3 +21,5 @@ export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({
     collapsed: true,
     status: TreeItemStatus.INITIAL
 });
+
+export const getTreePicker = <Value = {}>(id: string) => (state: TreePicker): Tree<TreePickerNode<Value>> | undefined => state[id];
\ No newline at end of file
diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts
new file mode 100644 (file)
index 0000000..8dc64a8
--- /dev/null
@@ -0,0 +1,194 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from "../store";
+import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
+import { loadCollectionPanel } from '~/store/collection-panel/collection-panel-action';
+import { snackbarActions } from '../snackbar/snackbar-actions';
+import { loadFavoritePanel } from '../favorite-panel/favorite-panel-action';
+import { openProjectPanel, projectPanelActions } from '~/store/project-panel/project-panel-action';
+import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions';
+import { loadResource, updateResources } from '../resources/resources-actions';
+import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action';
+import { projectPanelColumns } from '~/views/project-panel/project-panel';
+import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel';
+import { matchRootRoute } from '~/routes/routes';
+import { setCollectionBreadcrumbs, setProjectBreadcrumbs, setSidePanelBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
+import { navigateToProject } from '../navigation/navigation-action';
+import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+import { ServiceRepository } from '~/services/services';
+import { getResource } from '../resources/resources';
+import { getProjectPanelCurrentUuid } from '../project-panel/project-panel-action';
+import * as projectCreateActions from '~/store/projects/project-create-actions';
+import * as projectMoveActions from '~/store/projects/project-move-actions';
+import * as projectUpdateActions from '~/store/projects/project-update-actions';
+import * as collectionCreateActions from '~/store/collections/collection-create-actions';
+import * as collectionCopyActions from '~/store/collections/collection-copy-actions';
+import * as collectionUpdateActions from '~/store/collections/collection-update-actions';
+import * as collectionMoveActions from '~/store/collections/collection-move-actions';
+
+
+export const loadWorkbench = () =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        const { auth, router } = getState();
+        const { user } = auth;
+        if (user) {
+            const userResource = await dispatch<any>(loadResource(user.uuid));
+            if (userResource) {
+                dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
+                dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
+                dispatch<any>(initSidePanelTree());
+                if (router.location) {
+                    const match = matchRootRoute(router.location.pathname);
+                    if (match) {
+                        dispatch(navigateToProject(userResource.uuid));
+                    }
+                }
+            } else {
+                dispatch(userIsNotAuthenticated);
+            }
+        } else {
+            dispatch(userIsNotAuthenticated);
+        }
+    };
+
+export const loadFavorites = () =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES));
+        dispatch<any>(loadFavoritePanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
+    };
+
+
+export const loadProject = (uuid: string) =>
+    async (dispatch: Dispatch) => {
+        await dispatch<any>(activateSidePanelTreeItem(uuid));
+        dispatch<any>(setProjectBreadcrumbs(uuid));
+        dispatch<any>(openProjectPanel(uuid));
+        dispatch(loadDetailsPanel(uuid));
+    };
+
+export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) =>
+    async (dispatch: Dispatch) => {
+        const newProject = await dispatch<any>(projectCreateActions.createProject(data));
+        if (newProject) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully created.",
+                hideDuration: 2000
+            }));
+            await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
+            dispatch<any>(reloadProjectMatchingUuid([newProject.ownerUuid]));
+        }
+    };
+
+export const moveProject = (data: MoveToFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const oldProject = getResource(data.uuid)(getState().resources);
+            const oldOwnerUuid = oldProject ? oldProject.ownerUuid : '';
+            const movedProject = await dispatch<any>(projectMoveActions.moveProject(data));
+            if (movedProject) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000 }));
+                if (oldProject) {
+                    await dispatch<any>(loadSidePanelTreeProjects(oldProject.ownerUuid));
+                }
+                dispatch<any>(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid]));
+            }
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 }));
+        }
+    };
+
+export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const updatedProject = await dispatch<any>(projectUpdateActions.updateProject(data));
+        if (updatedProject) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully updated.",
+                hideDuration: 2000
+            }));
+            await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
+            dispatch<any>(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid]));
+        }
+    };
+
+export const loadCollection = (uuid: string) =>
+    async (dispatch: Dispatch) => {
+        const collection = await dispatch<any>(loadCollectionPanel(uuid));
+        await dispatch<any>(activateSidePanelTreeItem(collection.ownerUuid));
+        dispatch<any>(setCollectionBreadcrumbs(collection.uuid));
+        dispatch(loadDetailsPanel(uuid));
+    };
+
+export const createCollection = (data: collectionCreateActions.CollectionCreateFormDialogData) =>
+    async (dispatch: Dispatch) => {
+        const collection = await dispatch<any>(collectionCreateActions.createCollection(data));
+        if (collection) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully created.",
+                hideDuration: 2000
+            }));
+            dispatch<any>(updateResources([collection]));
+            dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+        }
+    };
+
+export const updateCollection = (data: collectionUpdateActions.CollectionUpdateFormDialogData) =>
+    async (dispatch: Dispatch) => {
+        const collection = await dispatch<any>(collectionUpdateActions.updateCollection(data));
+        if (collection) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully updated.",
+                hideDuration: 2000
+            }));
+            dispatch<any>(updateResources([collection]));
+            dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+        }
+    };
+
+export const copyCollection = (data: collectionCopyActions.CollectionCopyFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const collection = await dispatch<any>(collectionCopyActions.copyCollection(data));
+            dispatch<any>(updateResources([collection]));
+            dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied.', hideDuration: 2000 }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 }));
+        }
+    };
+
+export const moveCollection = (data: MoveToFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const collection = await dispatch<any>(collectionMoveActions.moveCollection(data));
+            dispatch<any>(updateResources([collection]));
+            dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been moved.', hideDuration: 2000 }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 }));
+        }
+    };
+
+export const resourceIsNotLoaded = (uuid: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `Resource identified by ${uuid} is not loaded.`
+    });
+
+export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({
+    message: 'User is not authenticated'
+});
+
+export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({
+    message: 'Could not load user'
+});
+
+const reloadProjectMatchingUuid = (matchingUuids: string[]) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
+        if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) {
+            dispatch<any>(loadProject(currentProjectPanelUuid));
+        }
+    };
\ No newline at end of file
index 3dc6d1a1acf51370c3c7b9c169745dd98ae184d8..41fce727289adb4570f920f3d98be6c51b260c38 100644 (file)
@@ -6,7 +6,6 @@ import { Redirect, RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
 import { getUserDetails, saveApiToken } from "~/store/auth/auth-action";
-import { getProjectList } from "~/store/project/project-action";
 import { getUrlParameter } from "~/common/url";
 import { AuthService } from "~/services/auth-service/auth-service";
 
@@ -20,10 +19,7 @@ export const ApiToken = connect()(
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
             this.props.dispatch(saveApiToken(apiToken));
-            this.props.dispatch<any>(getUserDetails()).then(() => {
-                const rootUuid = this.props.authService.getRootUuid();
-                this.props.dispatch(getProjectList(rootUuid));
-            });
+            this.props.dispatch<any>(getUserDetails());
         }
         render() {
             return <Redirect to="/"/>;
diff --git a/src/views-components/breadcrumbs/breadcrumbs.ts b/src/views-components/breadcrumbs/breadcrumbs.ts
new file mode 100644 (file)
index 0000000..c2f3389
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from '~/components/breadcrumbs/breadcrumbs';
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { navigateTo } from '~/store/navigation/navigation-action';
+import { getProperty } from '../../store/properties/properties';
+import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions';
+import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions';
+
+type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items'>;
+type BreadcrumbsActionProps = Pick<BreadcrumbsProps, 'onClick' | 'onContextMenu'>;
+
+const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({
+    items: getProperty<ResourceBreadcrumb[]>(BREADCRUMBS)(properties) || []
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({
+    onClick: ({ uuid }: ResourceBreadcrumb) => {
+        dispatch<any>(navigateTo(uuid));
+    },
+    onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => {
+        dispatch<any>(openSidePanelContextMenu(event, breadcrumb.uuid));
+    }
+});
+
+export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent);
\ No newline at end of file
index 3e99e10a709bca515d695c4ed734023bc229748a..1046995702ee11d9aab99531cfc781b651aa92b4 100644 (file)
@@ -15,6 +15,7 @@ import { ContextMenuKind } from "../context-menu/context-menu";
 import { Tree, getNodeChildrenIds, getNode } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
 import { openUploadCollectionFilesDialog } from '~/store/collections/uploader/collection-uploader-actions';
+import { openContextMenu } from '../../store/context-menu/context-menu-actions';
 
 const memoizedMapStateToProps = () => {
     let prevState: CollectionPanelFilesState;
@@ -43,17 +44,11 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps,
         dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
     },
     onItemMenuOpen: (event, item) => {
-        event.preventDefault();
-        dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
-            position: { x: event.clientX, y: event.clientY },
-            resource: { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id }
-        }));
+        dispatch<any>(openContextMenu(event, { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id }));
+    },
+    onOptionsMenuOpen: (event) => {
+        dispatch<any>(openContextMenu(event, { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' }));
     },
-    onOptionsMenuOpen: (event) =>
-        dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
-            position: { x: event.clientX, y: event.clientY },
-            resource: { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' }
-        }))
 });
 
 
index bc5d739ab342a5e501f54fa41e97d32889b04c9d..af10aedf804201b2a1c8bb662e1dcf4d15313936 100644 (file)
@@ -2,15 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { reset, initialize } from "redux-form";
-
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { NewProjectIcon, RenameIcon, CopyIcon, MoveToIcon } from "~/components/icon/icon";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
 import { openMoveProjectDialog } from '~/store/projects/project-move-actions';
-import { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions';
+import { openProjectCreateDialog } from '~/store/projects/project-create-actions';
 import { openProjectUpdateDialog } from '~/store/projects/project-update-actions';
 
 export const projectActionSet: ContextMenuActionSet = [[
@@ -18,7 +16,6 @@ export const projectActionSet: ContextMenuActionSet = [[
         icon: NewProjectIcon,
         name: "New project",
         execute: (dispatch, resource) => {
-            dispatch(reset(PROJECT_CREATE_FORM_NAME));
             dispatch<any>(openProjectCreateDialog(resource.uuid));
         }
     },
index 2c71abc41ed6b88cd0fa5094ccf807e38e148c13..386c5162f3c6ab9858c55f9b25468acbd98b353c 100644 (file)
@@ -2,20 +2,16 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { reset } from "redux-form";
-
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { projectActions } from "~/store/project/project-action";
-import { COLLECTION_CREATE_FORM_NAME, openCollectionCreateDialog } from '~/store/collections/collection-create-actions';
+import { openCollectionCreateDialog } from '~/store/collections/collection-create-actions';
 import { NewProjectIcon, CollectionIcon } from "~/components/icon/icon";
-import { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions';
+import { openProjectCreateDialog } from '~/store/projects/project-create-actions';
 
 export const rootProjectActionSet: ContextMenuActionSet =  [[
     {
         icon: NewProjectIcon,
         name: "New project",
         execute: (dispatch, resource) => {
-            dispatch(reset(PROJECT_CREATE_FORM_NAME));
             dispatch<any>(openProjectCreateDialog(resource.uuid));
         }
     },
@@ -23,7 +19,6 @@ export const rootProjectActionSet: ContextMenuActionSet =  [[
         icon: CollectionIcon,
         name: "New Collection",
         execute: (dispatch, resource) => {
-            dispatch(reset(COLLECTION_CREATE_FORM_NAME));
             dispatch<any>(openCollectionCreateDialog(resource.uuid));
         }
     }
index d548f607f550637cd5a120fc358c620d913bd29b..16dd59933411f5d394b31a58d5262fbff0418cda 100644 (file)
@@ -14,23 +14,18 @@ import { DataColumns } from "~/components/data-table/data-table";
 
 interface Props {
     id: string;
-    columns: DataColumns<any>;
     onRowClick: (item: any) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
     onRowDoubleClick: (item: any) => void;
     extractKey?: (item: any) => React.Key;
 }
 
-const mapStateToProps = (state: RootState, { id, columns }: Props) => {
-    const s = getDataExplorer(state.dataExplorer, id);
-    if (s.columns.length === 0) {
-        s.columns = columns;
-    }
-    return s;
+const mapStateToProps = (state: RootState, { id }: Props) => {
+    return getDataExplorer(state.dataExplorer, id);
 };
 
 const mapDispatchToProps = () => {
-    return (dispatch: Dispatch, { id, columns, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
+    return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
         onSetColumns: (columns: DataColumns<any>) => {
             dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
         },
index 1b07642ab727da755b898553ebc29a768f6bc025..abf1839286997117c0ecc56d2a5e87f917456e23 100644 (file)
@@ -9,9 +9,14 @@ import { ResourceKind } from '~/models/resource';
 import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '~/components/icon/icon';
 import { formatDate, formatFileSize } from '~/common/formatters';
 import { resourceLabel } from '~/common/labels';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+import { getResource } from '../../store/resources/resources';
+import { GroupContentsResource } from '~/services/groups-service/groups-service';
+import { ProcessResource } from '~/models/process';
 
 
-export const renderName = (item: {name: string; uuid: string, kind: string}) =>
+export const renderName = (item: { name: string; uuid: string, kind: string }) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
         <Grid item>
             {renderIcon(item)}
@@ -28,8 +33,13 @@ export const renderName = (item: {name: string; uuid: string, kind: string}) =>
         </Grid>
     </Grid>;
 
+export const ResourceName = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined;
+        return resource || { name: '', uuid: '', kind: '' };
+    })(renderName);
 
-export const renderIcon = (item: {kind: string}) => {
+export const renderIcon = (item: { kind: string }) => {
     switch (item.kind) {
         case ResourceKind.PROJECT:
             return <ProjectIcon />;
@@ -46,22 +56,52 @@ export const renderDate = (date: string) => {
     return <Typography noWrap>{formatDate(date)}</Typography>;
 };
 
+export const ResourceLastModifiedDate = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined;
+        return { date: resource ? resource.modifiedAt : '' };
+    })((props: { date: string }) => renderDate(props.date));
+
 export const renderFileSize = (fileSize?: number) =>
     <Typography noWrap>
         {formatFileSize(fileSize)}
     </Typography>;
 
+export const ResourceFileSize = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined;
+        return {};
+    })((props: { fileSize?: number }) => renderFileSize(props.fileSize));
+
 export const renderOwner = (owner: string) =>
     <Typography noWrap color="primary" >
         {owner}
     </Typography>;
 
+export const ResourceOwner = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined;
+        return { owner: resource ? resource.ownerUuid : '' };
+    })((props: { owner: string }) => renderOwner(props.owner));
+
 export const renderType = (type: string) =>
     <Typography noWrap>
         {resourceLabel(type)}
     </Typography>;
 
-export const renderStatus = (item: {status?: string}) =>
+export const ResourceType = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined;
+        return { type: resource ? resource.kind : '' };
+    })((props: { type: string }) => renderType(props.type));
+
+export const renderStatus = (item: { status?: string }) =>
     <Typography noWrap align="center" >
         {item.status || "-"}
     </Typography>;
+
+export const ProcessStatus = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource(props.uuid)(state.resources) as ProcessResource | undefined;
+        return { status: resource ? resource.state : '-' };
+    })((props: { status: string }) => renderType(props.status));
index 21d57aea4831a306a4333c1135fd71b39d7db3a4..7aae7860ac39999df6262fdb886198f49400a4e2 100644 (file)
@@ -20,6 +20,7 @@ import { ProcessDetails } from "./process-details";
 import { EmptyDetails } from "./empty-details";
 import { DetailsData } from "./details-data";
 import { DetailsResource } from "~/models/details";
+import { getResource } from '../../store/resources/resources';
 
 type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'headerTitle' | 'tabContainer';
 
@@ -70,10 +71,13 @@ const getItem = (resource: DetailsResource): DetailsData => {
     }
 };
 
-const mapStateToProps = ({ detailsPanel }: RootState) => ({
-    isOpened: detailsPanel.isOpened,
-    item: getItem(detailsPanel.item as DetailsResource)
-});
+const mapStateToProps = ({ detailsPanel, resources }: RootState) => {
+    const resource = getResource(detailsPanel.resourceUuid)(resources) as DetailsResource;
+    return {
+        isOpened: detailsPanel.isOpened,
+        item: getItem(resource)
+    };
+};
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     onCloseDrawer: () => {
@@ -110,7 +114,7 @@ export const DetailsPanel = withStyles(styles)(
                 const { tabsValue } = this.state;
                 return (
                     <Typography component="div"
-                                className={classnames([classes.container, { [classes.opened]: isOpened }])}>
+                        className={classnames([classes.container, { [classes.opened]: isOpened }])}>
                         <Drawer variant="permanent" anchor="right" classes={{ paper: classes.drawerPaper }}>
                             <Typography component="div" className={classes.headerContainer}>
                                 <Grid container alignItems='center' justify='space-around'>
@@ -124,14 +128,14 @@ export const DetailsPanel = withStyles(styles)(
                                     </Grid>
                                     <Grid item>
                                         <IconButton color="inherit" onClick={onCloseDrawer}>
-                                            {<CloseIcon/>}
+                                            {<CloseIcon />}
                                         </IconButton>
                                     </Grid>
                                 </Grid>
                             </Typography>
                             <Tabs value={tabsValue} onChange={this.handleChange}>
-                                <Tab disableRipple label="Details"/>
-                                <Tab disableRipple label="Activity" disabled/>
+                                <Tab disableRipple label="Details" />
+                                <Tab disableRipple label="Activity" disabled />
                             </Tabs>
                             {tabsValue === 0 && this.renderTabContainer(
                                 <Grid container direction="column">
@@ -139,7 +143,7 @@ export const DetailsPanel = withStyles(styles)(
                                 </Grid>
                             )}
                             {tabsValue === 1 && this.renderTabContainer(
-                                <Grid container direction="column"/>
+                                <Grid container direction="column" />
                             )}
                         </Drawer>
                     </Typography>
index ee6293abb8c0c64e5fe2431eba26837ea0d2be6a..245465fa55edfc2bcb32fab615122ec42c62a90f 100644 (file)
@@ -5,8 +5,9 @@
 import { compose } from "redux";
 import { withDialog } from "~/store/dialog/with-dialog";
 import { reduxForm } from 'redux-form';
-import { COLLECTION_COPY_FORM_NAME, CollectionCopyFormDialogData, copyCollection } from '~/store/collections/collection-copy-actions';
+import { COLLECTION_COPY_FORM_NAME, CollectionCopyFormDialogData } from '~/store/collections/collection-copy-actions';
 import { DialogCollectionCopy } from "~/views-components/dialog-copy/dialog-collection-copy";
+import { copyCollection } from '~/store/workbench/workbench-actions';
 
 export const CopyCollectionDialog = compose(
     withDialog(COLLECTION_COPY_FORM_NAME),
index d2699d8398b31cacbe6df8cf5535cf96291cfff8..581743e03fddfd6b1e7284bb9346315f4ee387c2 100644 (file)
@@ -5,16 +5,16 @@
 import { compose } from "redux";
 import { reduxForm } from 'redux-form';
 import { withDialog } from "~/store/dialog/with-dialog";
-import { addCollection, COLLECTION_CREATE_FORM_NAME, CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions';
-import { UploadFile } from "~/store/collections/uploader/collection-uploader-actions";
+import { COLLECTION_CREATE_FORM_NAME, CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions';
 import { DialogCollectionCreate } from "~/views-components/dialog-create/dialog-collection-create";
+import { createCollection } from "~/store/workbench/workbench-actions";
 
 export const CreateCollectionDialog = compose(
     withDialog(COLLECTION_CREATE_FORM_NAME),
     reduxForm<CollectionCreateFormDialogData>({
         form: COLLECTION_CREATE_FORM_NAME,
         onSubmit: (data, dispatch) => {
-            dispatch(addCollection(data));
+            dispatch(createCollection(data));
         }
     })
 )(DialogCollectionCreate);
index 2e87517c50f76f73b7daf09d1463ef4f634be78d..fc9fa2bc4d453c524a4d310b7ad4a2940109d303 100644 (file)
@@ -5,15 +5,16 @@
 import { compose } from "redux";
 import { reduxForm } from 'redux-form';
 import { withDialog } from "~/store/dialog/with-dialog";
-import { addProject, PROJECT_CREATE_FORM_NAME, ProjectCreateFormDialogData } from '~/store/projects/project-create-actions';
+import { PROJECT_CREATE_FORM_NAME, ProjectCreateFormDialogData } from '~/store/projects/project-create-actions';
 import { DialogProjectCreate } from '~/views-components/dialog-create/dialog-project-create';
+import { createProject } from "~/store/workbench/workbench-actions";
 
 export const CreateProjectDialog = compose(
     withDialog(PROJECT_CREATE_FORM_NAME),
     reduxForm<ProjectCreateFormDialogData>({
         form: PROJECT_CREATE_FORM_NAME,
         onSubmit: (data, dispatch) => {
-            dispatch(addProject(data));
+            dispatch(createProject(data));
         }
     })
 )(DialogProjectCreate);
\ No newline at end of file
index 38d6d0339288031485bcd51bbaf98932fc7bcdd7..fcdd999393ba7e765ad283110dd3a5ab1b7b7be3 100644 (file)
@@ -6,8 +6,9 @@ import { compose } from "redux";
 import { withDialog } from "~/store/dialog/with-dialog";
 import { reduxForm } from 'redux-form';
 import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to';
-import { COLLECTION_MOVE_FORM_NAME, moveCollection } from '~/store/collections/collection-move-actions';
+import { COLLECTION_MOVE_FORM_NAME } from '~/store/collections/collection-move-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+import { moveCollection } from '~/store/workbench/workbench-actions';
 
 export const MoveCollectionDialog = compose(
     withDialog(COLLECTION_MOVE_FORM_NAME),
index dd102b145f745d009cbfe2ee6cd7f90b918e525d..c1fbb76ebc5e973d70962ecea56c6f1359aaffa1 100644 (file)
@@ -6,9 +6,9 @@ import { compose } from "redux";
 import { withDialog } from "~/store/dialog/with-dialog";
 import { reduxForm } from 'redux-form';
 import { PROJECT_MOVE_FORM_NAME } from '~/store/projects/project-move-actions';
-import { moveProject } from '~/store/projects/project-move-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to';
+import { moveProject } from '~/store/workbench/workbench-actions';
 
 export const MoveProjectDialog = compose(
     withDialog(PROJECT_MOVE_FORM_NAME),
index 2c4296d816b6f2ff3de84a5a4b6372224eb44c06..cfa52639f4d830b6990cf2f35e9bb6b7f6b90763 100644 (file)
@@ -6,14 +6,15 @@ import { compose } from "redux";
 import { reduxForm } from 'redux-form';
 import { withDialog } from "~/store/dialog/with-dialog";
 import { DialogCollectionUpdate } from '~/views-components/dialog-update/dialog-collection-update';
-import { editCollection, COLLECTION_UPDATE_FORM_NAME, CollectionUpdateFormDialogData } from '~/store/collections/collection-update-actions';
+import { COLLECTION_UPDATE_FORM_NAME, CollectionUpdateFormDialogData } from '~/store/collections/collection-update-actions';
+import { updateCollection } from "~/store/workbench/workbench-actions";
 
 export const UpdateCollectionDialog = compose(
     withDialog(COLLECTION_UPDATE_FORM_NAME),
     reduxForm<CollectionUpdateFormDialogData>({
         form: COLLECTION_UPDATE_FORM_NAME,
         onSubmit: (data, dispatch) => {
-            dispatch(editCollection(data));
+            dispatch(updateCollection(data));
         }
     })
 )(DialogCollectionUpdate);
\ No newline at end of file
index 598d0b19f2cce82b8d67dc94025a221d17016d8d..36d5106bf13e3ef1798cb07b9d036db221d2240e 100644 (file)
@@ -6,14 +6,15 @@ import { compose } from "redux";
 import { reduxForm } from 'redux-form';
 import { withDialog } from "~/store/dialog/with-dialog";
 import { DialogProjectUpdate } from '~/views-components/dialog-update/dialog-project-update';
-import { editProject, PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from '~/store/projects/project-update-actions';
+import { PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from '~/store/projects/project-update-actions';
+import { updateProject } from '~/store/workbench/workbench-actions';
 
 export const UpdateProjectDialog = compose(
     withDialog(PROJECT_UPDATE_FORM_NAME),
     reduxForm<ProjectUpdateFormDialogData>({
         form: PROJECT_UPDATE_FORM_NAME,
         onSubmit: (data, dispatch) => {
-            dispatch(editProject(data));
+            dispatch(updateProject(data));
         }
     })
 )(DialogProjectUpdate);
\ No newline at end of file
index 75a39fd57ac74ecb72634ab7aadb563176670aaf..030fb353e3d3e52f15dbfd22f478126a87b55807 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from "react";
 import { mount, configure } from "enzyme";
 import * as Adapter from "enzyme-adapter-react-16";
-import { MainAppBar } from "./main-app-bar";
+import { MainAppBar, MainAppBarProps } from './main-app-bar';
 import { SearchBar } from "~/components/search-bar/search-bar";
 import { Breadcrumbs } from "~/components/breadcrumbs/breadcrumbs";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
@@ -27,10 +27,7 @@ describe("<MainAppBar />", () => {
     it("renders all components and the menu for authenticated user if user prop has value", () => {
         const mainAppBar = mount(
             <MainAppBar
-                user={user}
-                onContextMenu={jest.fn()}
-                onDetailsPanelToggle={jest.fn()}
-                {...{ searchText: "", breadcrumbs: [], menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onSearch: jest.fn(), onBreadcrumbClick: jest.fn(), onMenuItemClick: jest.fn() }}
+                {...mockMainAppBarProps({ user })}
             />
         );
         expect(mainAppBar.find(SearchBar)).toHaveLength(1);
@@ -42,10 +39,7 @@ describe("<MainAppBar />", () => {
         const menuItems = { accountMenu: [], helpMenu: [], anonymousMenu: [{ label: 'Sign in' }] };
         const mainAppBar = mount(
             <MainAppBar
-                menuItems={menuItems}
-                onDetailsPanelToggle={jest.fn()}
-                onContextMenu={jest.fn()}
-                {...{ searchText: "", breadcrumbs: [], onSearch: jest.fn(), onBreadcrumbClick: jest.fn(), onMenuItemClick: jest.fn() }}
+                {...mockMainAppBarProps({ user: undefined, menuItems })}
             />
         );
         expect(mainAppBar.find(SearchBar)).toHaveLength(0);
@@ -58,12 +52,7 @@ describe("<MainAppBar />", () => {
         const onSearch = jest.fn();
         const mainAppBar = mount(
             <MainAppBar
-                searchText="search text"
-                searchDebounce={2000}
-                onContextMenu={jest.fn()}
-                onSearch={onSearch}
-                onDetailsPanelToggle={jest.fn()}
-                {...{ user, breadcrumbs: [], menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onBreadcrumbClick: jest.fn(), onMenuItemClick: jest.fn() }}
+                {...mockMainAppBarProps({ searchText: 'search text', searchDebounce: 2000, onSearch, user })}
             />
         );
         const searchBar = mainAppBar.find(SearchBar);
@@ -73,34 +62,12 @@ describe("<MainAppBar />", () => {
         expect(onSearch).toBeCalledWith("new search text");
     });
 
-    it("communicates with <Breadcrumbs />", () => {
-        const items = [{ label: "breadcrumb 1" }];
-        const onBreadcrumbClick = jest.fn();
-        const mainAppBar = mount(
-            <MainAppBar
-                breadcrumbs={items}
-                onContextMenu={jest.fn()}
-                onBreadcrumbClick={onBreadcrumbClick}
-                onDetailsPanelToggle={jest.fn()}
-                {...{ user, searchText: "", menuItems: { accountMenu: [], helpMenu: [], anonymousMenu: [] }, onSearch: jest.fn(), onMenuItemClick: jest.fn() }}
-            />
-        );
-        const breadcrumbs = mainAppBar.find(Breadcrumbs);
-        expect(breadcrumbs.prop("items")).toBe(items);
-        breadcrumbs.prop("onClick")(items[0]);
-        expect(onBreadcrumbClick).toBeCalledWith(items[0]);
-    });
-
     it("communicates with menu", () => {
         const onMenuItemClick = jest.fn();
-        const menuItems = { accountMenu: [{label: "log out"}], helpMenu: [], anonymousMenu: [] };
+        const menuItems = { accountMenu: [{ label: "log out" }], helpMenu: [], anonymousMenu: [] };
         const mainAppBar = mount(
             <MainAppBar
-                menuItems={menuItems}
-                onContextMenu={jest.fn()}
-                onMenuItemClick={onMenuItemClick}
-                onDetailsPanelToggle={jest.fn()}
-                {...{ user, searchText: "", breadcrumbs: [], onSearch: jest.fn(), onBreadcrumbClick: jest.fn() }}
+                {...mockMainAppBarProps({ menuItems, onMenuItemClick, user })}
             />
         );
 
@@ -109,3 +76,20 @@ describe("<MainAppBar />", () => {
         expect(onMenuItemClick).toBeCalledWith(menuItems.accountMenu[0]);
     });
 });
+
+const Breadcrumbs = () => <span>Breadcrumbs</span>;
+
+const mockMainAppBarProps = (props: Partial<MainAppBarProps>): MainAppBarProps => ({
+    searchText: '',
+    breadcrumbs: Breadcrumbs,
+    menuItems: {
+        accountMenu: [],
+        helpMenu: [],
+        anonymousMenu: [],
+    },
+    buildInfo: '',
+    onSearch: jest.fn(),
+    onMenuItemClick: jest.fn(),
+    onDetailsPanelToggle: jest.fn(),
+    ...props,
+});
index 54d6a5da0ec8c5306734ba27861a5288fd21fd89..de6be7e7948fa78fbf8a232db386139a351052a1 100644 (file)
@@ -6,7 +6,6 @@ import * as React from "react";
 import { AppBar, Toolbar, Typography, Grid, IconButton, Badge, Button, MenuItem } from "@material-ui/core";
 import { User, getUserFullname } from "~/models/user";
 import { SearchBar } from "~/components/search-bar/search-bar";
-import { Breadcrumbs, Breadcrumb } from "~/components/breadcrumbs/breadcrumbs";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { DetailsIcon, NotificationIcon, UserPanelIcon, HelpIcon } from "~/components/icon/icon";
 
@@ -23,7 +22,7 @@ export interface MainAppBarMenuItems {
 interface MainAppBarDataProps {
     searchText: string;
     searchDebounce?: number;
-    breadcrumbs: Breadcrumb[];
+    breadcrumbs: React.ComponentType<any>;
     user?: User;
     menuItems: MainAppBarMenuItems;
     buildInfo: string;
@@ -31,13 +30,11 @@ interface MainAppBarDataProps {
 
 export interface MainAppBarActionProps {
     onSearch: (searchText: string) => void;
-    onBreadcrumbClick: (breadcrumb: Breadcrumb) => void;
     onMenuItemClick: (menuItem: MainAppBarMenuItem) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
     onDetailsPanelToggle: () => void;
 }
 
-type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps;
+export type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps;
 
 export const MainAppBar: React.SFC<MainAppBarProps> = (props) => {
     return <AppBar position="static">
@@ -68,15 +65,10 @@ export const MainAppBar: React.SFC<MainAppBarProps> = (props) => {
             </Grid>
         </Toolbar>
         <Toolbar >
-            {
-                props.user && <Breadcrumbs
-                    items={props.breadcrumbs}
-                    onClick={props.onBreadcrumbClick}
-                    onContextMenu={props.onContextMenu} />
-            }
-            { props.user && <IconButton color="inherit" onClick={props.onDetailsPanelToggle}>
-                    <DetailsIcon />
-                </IconButton>
+            {props.user && <props.breadcrumbs />}
+            {props.user && <IconButton color="inherit" onClick={props.onDetailsPanelToggle}>
+                <DetailsIcon />
+            </IconButton>
             }
         </Toolbar>
     </AppBar>;
index 92c54515b5355152fd2eb634a225309c18ce8388..3859180f0e2d81f0308070d399a1b836d5fa277a 100644 (file)
@@ -18,9 +18,10 @@ import { ServiceRepository } from "~/services/services";
 import { FilterBuilder } from "~/common/api/filter-builder";
 import { WrappedFieldProps } from 'redux-form';
 
-type ProjectTreePickerProps = Pick<TreePickerProps, 'toggleItemActive' | 'toggleItemOpen'>;
+type ProjectTreePickerProps = Pick<TreePickerProps, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen'>;
 
 const mapDispatchToProps = (dispatch: Dispatch, props: { onChange: (projectUuid: string) => void }): ProjectTreePickerProps => ({
+    onContextMenu: () => { return; },
     toggleItemActive: (nodeId, status, pickerId) => {
         getNotSelectedTreePickerKind(pickerId)
             .forEach(pickerId => dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '', pickerId })));
diff --git a/src/views-components/side-panel-tree/side-panel-tree.tsx b/src/views-components/side-panel-tree/side-panel-tree.tsx
new file mode 100644 (file)
index 0000000..d0b00d6
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
+import { TreeItem } from "~/components/tree/tree";
+import { ProjectResource } from "~/models/project";
+import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
+import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon } from '~/components/icon/icon';
+import { RecentIcon, WorkflowIcon } from '~/components/icon/icon';
+import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions';
+
+export interface SidePanelTreeProps {
+    onItemActivation: (id: string) => void;
+}
+
+type SidePanelTreeActionProps = Pick<TreePickerProps, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen'>;
+
+const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): SidePanelTreeActionProps => ({
+    onContextMenu: (event, id) => {
+        dispatch<any>(openSidePanelContextMenu(event, id));
+    },
+    toggleItemActive: (nodeId) => {
+        dispatch<any>(activateSidePanelTreeItem(nodeId));
+        props.onItemActivation(nodeId);
+    },
+    toggleItemOpen: (nodeId) => {
+        dispatch<any>(toggleSidePanelTreeItemCollapse(nodeId));
+    }
+});
+
+export const SidePanelTree = connect(undefined, mapDispatchToProps)(
+    (props: SidePanelTreeActionProps) =>
+        <TreePicker {...props} render={renderSidePanelItem} pickerId={SIDE_PANEL_TREE} />);
+
+const renderSidePanelItem = (item: TreeItem<ProjectResource>) =>
+    <ListItemTextIcon
+        icon={getProjectPickerIcon(item)}
+        name={typeof item.data === 'string' ? item.data : item.data.name}
+        isActive={item.active}
+        hasMargin={true} />;
+
+const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
+    typeof item.data === 'string'
+        ? getSidePanelIcon(item.data)
+        : ProjectIcon;
+
+const getSidePanelIcon = (category: string) => {
+    switch (category) {
+        case SidePanelTreeCategory.FAVORITES:
+            return FavoriteIcon;
+        case SidePanelTreeCategory.PROJECTS:
+            return ProjectsIcon;
+        case SidePanelTreeCategory.RECENT_OPEN:
+            return RecentIcon;
+        case SidePanelTreeCategory.SHARED_WITH_ME:
+            return ShareMeIcon;
+        case SidePanelTreeCategory.TRASH:
+            return TrashIcon;
+        case SidePanelTreeCategory.WORKFLOWS:
+            return WorkflowIcon;
+        default:
+            return ProjectIcon;
+    }
+};
diff --git a/src/views-components/side-panel/side-panel.tsx b/src/views-components/side-panel/side-panel.tsx
new file mode 100644 (file)
index 0000000..b81f39e
--- /dev/null
@@ -0,0 +1,45 @@
+// 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/styles';
+import Drawer from '@material-ui/core/Drawer';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { SidePanelTree, SidePanelTreeProps } from '~/views-components/side-panel-tree/side-panel-tree';
+import { compose, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { navigateFromSidePanel } from '../../store/side-panel/side-panel-action';
+
+const DRAWER_WITDH = 240;
+
+type CssRules = 'drawerPaper' | 'toolbar';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    drawerPaper: {
+        position: 'relative',
+        width: DRAWER_WITDH,
+        display: 'flex',
+        flexDirection: 'column',
+        paddingTop: 58,
+        overflow: 'auto',
+    },
+    toolbar: theme.mixins.toolbar
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): SidePanelTreeProps => ({
+    onItemActivation: id => {
+        dispatch<any>(navigateFromSidePanel(id));
+    }
+});
+
+export const SidePanel = compose(
+    withStyles(styles),
+    connect(undefined, mapDispatchToProps)
+)(({ classes, ...props }: WithStyles<CssRules> & SidePanelTreeProps) =>
+    <Drawer
+        variant="permanent"
+        classes={{ paper: classes.drawerPaper }}>
+        <div className={classes.toolbar} />
+        <SidePanelTree {...props} />
+    </Drawer>);
index b90f2e420656dd1365b37869c65779830726a589..8b7630ab8cc62609ca9a3898653f1956bd821553 100644 (file)
@@ -11,25 +11,34 @@ import { Dispatch } from "redux";
 
 export interface TreePickerProps {
     pickerId: string;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, nodeId: string, pickerId: string) => void;
     toggleItemOpen: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
     toggleItemActive: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
 }
 
-const mapStateToProps = (state: RootState, props: TreePickerProps): Pick<TreeProps<any>, 'items'> => {
-    const tree = state.treePicker[props.pickerId] || createTree();
-    return {
-        items: getNodeChildrenIds('')(tree)
-            .map(treePickerToTreeItems(tree))
+const memoizedMapStateToProps = () => {
+    let prevTree: Ttree<TreePickerNode>;
+    let mappedProps: Pick<TreeProps<any>, 'items'>;
+    return (state: RootState, props: TreePickerProps): Pick<TreeProps<any>, 'items'> => {
+        const tree = state.treePicker[props.pickerId] || createTree();
+        if(tree !== prevTree){
+            prevTree = tree;
+            mappedProps = {
+                items: getNodeChildrenIds('')(tree)
+                    .map(treePickerToTreeItems(tree))
+            };
+        }
+        return mappedProps;
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch, props: TreePickerProps): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive'> => ({
-    onContextMenu: () => { return; },
+    onContextMenu: (event, item) => props.onContextMenu(event, item.id, props.pickerId),
     toggleItemActive: (id, status) => props.toggleItemActive(id, status, props.pickerId),
     toggleItemOpen: (id, status) => props.toggleItemOpen(id, status, props.pickerId)
 });
 
-export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
+export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
 
 const treePickerToTreeItems = (tree: Ttree<TreePickerNode>) =>
     (id: string): TreeItem<any> => {
index 559d4a9a86072ee3bc0f0750b94166bf72017d43..348b548bdb4845eef161ce30210bcf5fde7833d0 100644 (file)
@@ -20,6 +20,9 @@ import { TagResource } from '~/models/tag';
 import { CollectionTagForm } from './collection-tag-form';
 import { deleteCollectionTag } from '~/store/collection-panel/collection-panel-action';
 import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { getResource } from '~/store/resources/resources';
+import { contextMenuActions, openContextMenu } from '~/store/context-menu/context-menu-actions';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 
 type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'label' | 'value';
 
@@ -55,81 +58,90 @@ interface CollectionPanelDataProps {
     tags: TagResource[];
 }
 
-interface CollectionPanelActionProps {
-    onItemRouteChange: (collectionId: string) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: CollectionResource) => void;
-}
-
-type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps & DispatchProp
-                            & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+type CollectionPanelProps = CollectionPanelDataProps & DispatchProp
+    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 
 export const CollectionPanel = withStyles(styles)(
-    connect((state: RootState) => ({
-        item: state.collectionPanel.item,
-        tags: state.collectionPanel.tags
-    }))(
+    connect((state: RootState, props: RouteComponentProps<{ id: string }>) => {
+        const collection = getResource(props.match.params.id)(state.resources);
+        return {
+            item: collection,
+            tags: state.collectionPanel.tags
+        };
+    })(
         class extends React.Component<CollectionPanelProps> {
 
             render() {
-                const { classes, item, tags, onContextMenu } = this.props;
+                const { classes, item, tags } = this.props;
                 return <div>
-                        <Card className={classes.card}>
-                            <CardHeader
-                                avatar={ <CollectionIcon className={classes.iconHeader} /> }
-                                action={
-                                    <IconButton
-                                        aria-label="More options"
-                                        onClick={event => onContextMenu(event, item)}>
-                                        <MoreOptionsIcon />
-                                    </IconButton>
-                                }
-                                title={item && item.name }
-                                subheader={item && item.description} />
-                            <CardContent>
-                                <Grid container direction="column">
-                                    <Grid item xs={6}>
-                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                                                label='Collection UUID'
-                                                value={item && item.uuid}>
-                                            <Tooltip title="Copy uuid">
-                                                <CopyToClipboard text={item && item.uuid} onCopy={() => this.onCopy() }>
-                                                    <CopyIcon className={classes.copyIcon} />
-                                                </CopyToClipboard>
-                                            </Tooltip>
-                                        </DetailsAttribute>
-                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
-                                            label='Number of files' value='14' />
-                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
-                                            label='Content size' value='54 MB' />
-                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
-                                            label='Owner' value={item && item.ownerUuid} />
-                                    </Grid>
+                    <Card className={classes.card}>
+                        <CardHeader
+                            avatar={<CollectionIcon className={classes.iconHeader} />}
+                            action={
+                                <IconButton
+                                    aria-label="More options"
+                                    onClick={this.handleContextMenu}>
+                                    <MoreOptionsIcon />
+                                </IconButton>
+                            }
+                            title={item && item.name}
+                            subheader={item && item.description} />
+                        <CardContent>
+                            <Grid container direction="column">
+                                <Grid item xs={6}>
+                                    <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                                        label='Collection UUID'
+                                        value={item && item.uuid}>
+                                        <Tooltip title="Copy uuid">
+                                            <CopyToClipboard text={item && item.uuid} onCopy={() => this.onCopy()}>
+                                                <CopyIcon className={classes.copyIcon} />
+                                            </CopyToClipboard>
+                                        </Tooltip>
+                                    </DetailsAttribute>
+                                    <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                                        label='Number of files' value='14' />
+                                    <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                                        label='Content size' value='54 MB' />
+                                    <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                                        label='Owner' value={item && item.ownerUuid} />
                                 </Grid>
-                            </CardContent>
-                        </Card>
+                            </Grid>
+                        </CardContent>
+                    </Card>
 
-                        <Card className={classes.card}>
-                            <CardHeader title="Properties" />
-                            <CardContent>
-                                <Grid container direction="column">
-                                    <Grid item xs={12}><CollectionTagForm /></Grid>
-                                    <Grid item xs={12}>
-                                        {
-                                            tags.map(tag => {
-                                                return <Chip key={tag.etag} className={classes.tag}
-                                                    onDelete={this.handleDelete(tag.uuid)}
-                                                    label={renderTagLabel(tag)}  />;
-                                            })
-                                        }
-                                    </Grid>
+                    <Card className={classes.card}>
+                        <CardHeader title="Properties" />
+                        <CardContent>
+                            <Grid container direction="column">
+                                <Grid item xs={12}><CollectionTagForm /></Grid>
+                                <Grid item xs={12}>
+                                    {
+                                        tags.map(tag => {
+                                            return <Chip key={tag.etag} className={classes.tag}
+                                                onDelete={this.handleDelete(tag.uuid)}
+                                                label={renderTagLabel(tag)} />;
+                                        })
+                                    }
                                 </Grid>
-                            </CardContent>
-                        </Card>
-                        <div className={classes.card}>
-                            <CollectionPanelFiles/>
-                        </div>
-                    </div>;
+                            </Grid>
+                        </CardContent>
+                    </Card>
+                    <div className={classes.card}>
+                        <CollectionPanelFiles />
+                    </div>
+                </div>;
+            }
+
+            handleContextMenu = (event: React.MouseEvent<any>) => {
+                const { uuid, name, description } = this.props.item;
+                const resource = {
+                    uuid,
+                    name,
+                    description,
+                    kind: ContextMenuKind.COLLECTION
+                };
+                this.props.dispatch<any>(openContextMenu(event, resource));
             }
 
             handleDelete = (uuid: string) => () => {
@@ -143,12 +155,6 @@ export const CollectionPanel = withStyles(styles)(
                 }));
             }
 
-            componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
-                if (!item || match.params.id !== item.uuid) {
-                    onItemRouteChange(match.params.id);
-                }
-            }
-
         }
     )
 );
diff --git a/src/views/favorite-panel/favorite-panel-item.ts b/src/views/favorite-panel/favorite-panel-item.ts
deleted file mode 100644 (file)
index 842b6d6..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { GroupContentsResource } from "~/services/groups-service/groups-service";
-import { ResourceKind } from "~/models/resource";
-
-export interface FavoritePanelItem {
-    uuid: string;
-    name: string;
-    kind: string;
-    url: string;
-    owner: string;
-    lastModified: string;
-    fileSize?: number;
-    status?: string;
-}
-
-export function resourceToDataItem(r: GroupContentsResource): FavoritePanelItem {
-    return {
-        uuid: r.uuid,
-        name: r.name,
-        kind: r.kind,
-        url: "",
-        owner: r.ownerUuid,
-        lastModified: r.modifiedAt,
-        status:  r.kind === ResourceKind.PROCESS ? r.state : undefined
-    };
-}
index 125ea27ddf1635836bdd592091025d637efd028a..9fbae5ced889902d4771af6dbc7bb821e6f15360 100644 (file)
@@ -3,22 +3,25 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { FavoritePanelItem } from './favorite-panel-item';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
 import { DispatchProp, connect } from 'react-redux';
 import { DataColumns } from '~/components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
-import { RootState } from '~/store/store';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 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 { ArvadosTheme } from '~/common/custom-theme';
-import { renderName, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '~/views-components/data-explorer/renderers';
 import { FAVORITE_PANEL_ID } from "~/store/favorite-panel/favorite-panel-action";
+import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner, ResourceName } from '~/views-components/data-explorer/renderers';
 import { FavoriteIcon } from '~/components/icon/icon';
+import { Dispatch } from 'redux';
+import { contextMenuActions, openContextMenu, resourceKindToContextMenuKind } from '~/store/context-menu/context-menu-actions';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { loadDetailsPanel } from '../../store/details-panel/details-panel-action';
+import { navigateTo } from '~/store/navigation/navigation-action';
 
 type CssRules = "toolbar" | "button";
 
@@ -45,14 +48,14 @@ export interface FavoritePanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
+export const favoritePanelColumns: DataColumns<string, FavoritePanelFilter> = [
     {
         name: FavoritePanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
         filters: [],
-        render: renderName,
+        render: uuid => <ResourceName uuid={uuid} />,
         width: "450px"
     },
     {
@@ -77,7 +80,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
                 type: ContainerRequestState.UNCOMMITTED
             }
         ],
-        render: renderStatus,
+        render: uuid => <ProcessStatus uuid={uuid} />,
         width: "75px"
     },
     {
@@ -102,7 +105,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
                 type: ResourceKind.PROJECT
             }
         ],
-        render: item => renderType(item.kind),
+        render: uuid => <ResourceType uuid={uuid} />,
         width: "125px"
     },
     {
@@ -111,7 +114,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderOwner(item.owner),
+        render: uuid => <ResourceOwner uuid={uuid} />,
         width: "200px"
     },
     {
@@ -120,7 +123,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderFileSize(item.fileSize),
+        render: uuid => <ResourceFileSize uuid={uuid} />,
         width: "50px"
     },
     {
@@ -129,7 +132,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderDate(item.lastModified),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />,
         width: "150px"
     }
 ];
@@ -139,36 +142,42 @@ interface FavoritePanelDataProps {
 }
 
 interface FavoritePanelActionProps {
-    onItemClick: (item: FavoritePanelItem) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: FavoritePanelItem) => void;
+    onItemClick: (item: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string) => void;
     onDialogOpen: (ownerUuid: string) => void;
-    onItemDoubleClick: (item: FavoritePanelItem) => void;
-    onItemRouteChange: (itemId: string) => void;
+    onItemDoubleClick: (item: string) => void;
 }
 
+const mapDispatchToProps = (dispatch: Dispatch): FavoritePanelActionProps => ({
+    onContextMenu: (event, resourceUuid) => {
+        const kind = resourceKindToContextMenuKind(resourceUuid);
+        if (kind) {
+            dispatch<any>(openContextMenu(event, { name: '', uuid: resourceUuid, kind }));
+        }
+    },
+    onDialogOpen: (ownerUuid: string) => { return; },
+    onItemClick: (resourceUuid: string) => {
+        dispatch<any>(loadDetailsPanel(resourceUuid));
+    },
+    onItemDoubleClick: uuid => {
+        dispatch<any>(navigateTo(uuid));
+    }
+});
+
 type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp
-                        & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 export const FavoritePanel = withStyles(styles)(
-    connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
+    connect(undefined, mapDispatchToProps)(
         class extends React.Component<FavoritePanelProps> {
             render() {
                 return <DataExplorer
                     id={FAVORITE_PANEL_ID}
-                    columns={columns}
                     onRowClick={this.props.onItemClick}
                     onRowDoubleClick={this.props.onItemDoubleClick}
                     onContextMenu={this.props.onContextMenu}
-                    extractKey={(item: FavoritePanelItem) => item.uuid}
                     defaultIcon={FavoriteIcon}
-                    defaultMessages={['Your favorites list is empty.']}/>
-                ;
-            }
-
-            componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: FavoritePanelProps) {
-                if (match.params.id !== currentItemId) {
-                    onItemRouteChange(match.params.id);
-                }
+                    defaultMessages={['Your favorites list is empty.']} />;
             }
         }
     )
index 08bd37015d228837d3834952bacb4ed8041a40a2..70a7a405ac8e83ee6c66ee18689bad107c46683c 100644 (file)
@@ -14,6 +14,8 @@ import { RouteComponentProps } from 'react-router';
 import { MoreOptionsIcon, ProcessIcon } from '~/components/icon/icon';
 import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
 import { RootState } from '~/store/store';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { openContextMenu } from '~/store/context-menu/context-menu-actions';
 
 type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'content' | 'chip' | 'headerText' | 'link';
 
@@ -83,7 +85,7 @@ export const ProcessPanel = withStyles(styles)(
     }))(
         class extends React.Component<ProcessPanelProps> {
             render() {
-                const { classes, onContextMenu, item } = this.props;
+                const { classes } = this.props;
 
                 return <div>
                     <Card className={classes.card}>
@@ -92,12 +94,11 @@ export const ProcessPanel = withStyles(styles)(
                             action={
                                 <IconButton
                                     aria-label="More options"
-                                    onClick={event => onContextMenu(event, item)}>
+                                    onClick={this.handleContextMenu}>
                                     <MoreOptionsIcon />
                                 </IconButton>
                             }
-                            title="Pipeline template that generates a config file from a template"
-                             />
+                            title="Pipeline template that generates a config file from a template" />
                         <CardContent className={classes.content}>
                             <Grid container direction="column">
                                 <Grid item xs={8}>
@@ -124,6 +125,17 @@ export const ProcessPanel = withStyles(styles)(
                     </Card>
                 </div>;
             }
+
+            handleContextMenu = (event: React.MouseEvent<any>) => {
+                // const { uuid, name, description } = this.props.item;
+                const resource = {
+                    uuid: '',
+                    name: '',
+                    description: '',
+                    kind: ContextMenuKind.PROCESS
+                };
+                this.props.dispatch<any>(openContextMenu(event, resource));
+            }
         }
     )
 );
\ No newline at end of file
diff --git a/src/views/project-panel/project-panel-item.ts b/src/views/project-panel/project-panel-item.ts
deleted file mode 100644 (file)
index f031859..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { GroupContentsResource } from "~/services/groups-service/groups-service";
-import { ResourceKind } from "~/models/resource";
-
-export interface ProjectPanelItem {
-    uuid: string;
-    name: string;
-    description?: string;
-    kind: string;
-    url: string;
-    owner: string;
-    lastModified: string;
-    fileSize?: number;
-    status?: string;
-}
-
-export function resourceToDataItem(r: GroupContentsResource): ProjectPanelItem {
-    return {
-        uuid: r.uuid,
-        name: r.name,
-        description: r.description,
-        kind: r.kind,
-        url: "",
-        owner: r.ownerUuid,
-        lastModified: r.modifiedAt,
-        status:  r.kind === ResourceKind.PROCESS ? r.state : undefined
-    };
-}
index 0f958d2cfbbb87684c47c1d77a16319f3e494e28..06946430e71909d711f1bdc6c32b1ac4c0f80021 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { ProjectPanelItem } from './project-panel-item';
 import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
 import { DispatchProp, connect } from 'react-redux';
@@ -16,9 +15,20 @@ import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind } from '~/models/resource';
 import { resourceLabel } from '~/common/labels';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { renderName, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '~/views-components/data-explorer/renderers';
-import { restoreBranch } from '~/store/navigation/navigation-action';
+import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner } from '~/views-components/data-explorer/renderers';
 import { ProjectIcon } from '~/components/icon/icon';
+import { ResourceName } from '~/views-components/data-explorer/renderers';
+import { ResourcesState, getResource } from '~/store/resources/resources';
+import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { contextMenuActions, resourceKindToContextMenuKind, openContextMenu } from '~/store/context-menu/context-menu-actions';
+import { CollectionResource } from '~/models/collection';
+import { ProjectResource } from '~/models/project';
+import { navigateTo } from '~/store/navigation/navigation-action';
+import { getProperty } from '~/store/properties/properties';
+import { PROJECT_PANEL_CURRENT_UUID } from '~/store/project-panel/project-panel-action';
+import { openCollectionCreateDialog } from '../../store/collections/collection-create-actions';
+import { openProjectCreateDialog } from '~/store/projects/project-create-actions';
 
 type CssRules = 'root' | "toolbar" | "button";
 
@@ -50,14 +60,14 @@ export interface ProjectPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
+export const projectPanelColumns: DataColumns<string, ProjectPanelFilter> = [
     {
         name: ProjectPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
         filters: [],
-        render: renderName,
+        render: uuid => <ResourceName uuid={uuid} />,
         width: "450px"
     },
     {
@@ -82,7 +92,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
                 type: ContainerRequestState.UNCOMMITTED
             }
         ],
-        render: renderStatus,
+        render: uuid => <ProcessStatus uuid={uuid} />,
         width: "75px"
     },
     {
@@ -107,7 +117,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
                 type: ResourceKind.PROJECT
             }
         ],
-        render: item => renderType(item.kind),
+        render: uuid => <ResourceType uuid={uuid} />,
         width: "125px"
     },
     {
@@ -116,7 +126,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderOwner(item.owner),
+        render: uuid => <ResourceOwner uuid={uuid} />,
         width: "200px"
     },
     {
@@ -125,7 +135,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderFileSize(item.fileSize),
+        render: uuid => <ResourceFileSize uuid={uuid} />,
         width: "50px"
     },
     {
@@ -134,7 +144,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: [],
-        render: item => renderDate(item.lastModified),
+        render: uuid => <ResourceLastModifiedDate uuid={uuid} />,
         width: "150px"
     }
 ];
@@ -143,22 +153,17 @@ export const PROJECT_PANEL_ID = "projectPanel";
 
 interface ProjectPanelDataProps {
     currentItemId: string;
+    resources: ResourcesState;
 }
 
-interface ProjectPanelActionProps {
-    onItemClick: (item: ProjectPanelItem) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: ProjectPanelItem) => void;
-    onProjectCreationDialogOpen: (ownerUuid: string) => void;
-    onCollectionCreationDialogOpen: (ownerUuid: string) => void;
-    onItemDoubleClick: (item: ProjectPanelItem) => void;
-    onItemRouteChange: (itemId: string) => void;
-}
-
-type ProjectPanelProps = ProjectPanelDataProps & ProjectPanelActionProps & DispatchProp
+type ProjectPanelProps = ProjectPanelDataProps & DispatchProp
     & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 export const ProjectPanel = withStyles(styles)(
-    connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
+    connect((state: RootState) => ({
+        currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
+        resources: state.resources
+    }))(
         class extends React.Component<ProjectPanelProps> {
             render() {
                 const { classes } = this.props;
@@ -176,35 +181,37 @@ export const ProjectPanel = withStyles(styles)(
                     </div>
                     <DataExplorer
                         id={PROJECT_PANEL_ID}
-                        columns={columns}
-                        onRowClick={this.props.onItemClick}
-                        onRowDoubleClick={this.props.onItemDoubleClick}
-                        onContextMenu={this.props.onContextMenu}
-                        extractKey={(item: ProjectPanelItem) => item.uuid}
+                        onRowClick={this.handleRowClick}
+                        onRowDoubleClick={this.handleRowDoubleClick}
+                        onContextMenu={this.handleContextMenu}
                         defaultIcon={ProjectIcon}
                         defaultMessages={['Your project is empty.', 'Please create a project or create a collection and upload a data.']} />
                 </div>;
             }
 
             handleNewProjectClick = () => {
-                this.props.onProjectCreationDialogOpen(this.props.currentItemId);
+                this.props.dispatch<any>(openProjectCreateDialog(this.props.currentItemId));
             }
 
             handleNewCollectionClick = () => {
-                this.props.onCollectionCreationDialogOpen(this.props.currentItemId);
+                this.props.dispatch<any>(openCollectionCreateDialog(this.props.currentItemId));
             }
 
-            componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: ProjectPanelProps) {
-                if (match.params.id !== currentItemId) {
-                    onItemRouteChange(match.params.id);
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const kind = resourceKindToContextMenuKind(resourceUuid);
+                if (kind) {
+                    this.props.dispatch<any>(openContextMenu(event, { name: '', uuid: resourceUuid, kind }));
                 }
             }
 
-            componentDidMount() {
-                if (this.props.match.params.id && this.props.currentItemId === '') {
-                    this.props.dispatch<any>(restoreBranch(this.props.match.params.id));
-                }
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.dispatch<any>(navigateTo(uuid));
             }
+
+            handleRowClick = (uuid: string) => {
+                this.props.dispatch(loadDetailsPanel(uuid));
+            }
+
         }
     )
 );
index d8daed58b6ce1a421dc724bf47e0fe097b1c08bc..27470fa43086c2ebdd78630eec1db39b01ecdefc 100644 (file)
@@ -4,62 +4,43 @@
 
 import * as React from 'react';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import Drawer from '@material-ui/core/Drawer';
 import { connect, DispatchProp } from "react-redux";
-import { Route, RouteComponentProps, Switch, Redirect } from "react-router";
+import { Route, Switch } from "react-router";
 import { login, logout } from "~/store/auth/auth-action";
 import { User } from "~/models/user";
 import { RootState } from "~/store/store";
 import { MainAppBar, MainAppBarActionProps, MainAppBarMenuItem } from '~/views-components/main-app-bar/main-app-bar';
-import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs';
 import { push } from 'react-router-redux';
-import { reset } from 'redux-form';
-import { ProjectTree } from '~/views-components/project-tree/project-tree';
-import { TreeItem } from "~/components/tree/tree";
-import { getTreePath } from '~/store/project/project-reducer';
-import { sidePanelActions } from '~/store/side-panel/side-panel-action';
-import { SidePanel, SidePanelItem } from '~/components/side-panel/side-panel';
-import { ItemMode, setProjectItem } from "~/store/navigation/navigation-action";
-import { projectActions } from "~/store/project/project-action";
+import { ProjectPanel } from "~/views/project-panel/project-panel";
 import { DetailsPanel } from '~/views-components/details-panel/details-panel';
 import { ArvadosTheme } from '~/common/custom-theme';
-
-import { detailsPanelActions, loadDetails } from "~/store/details-panel/details-panel-action";
-import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
-import { ProjectResource } from '~/models/project';
-import { ResourceKind } from '~/models/resource';
-import { ProcessPanel } from '~/views/process-panel/process-panel';
-import { ContextMenu, ContextMenuKind } from "~/views-components/context-menu/context-menu";
+import { detailsPanelActions } from "~/store/details-panel/details-panel-action";
+import { ContextMenu } from "~/views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
 import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog';
 import { Snackbar } from '~/views-components/snackbar/snackbar';
-import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action';
 import { CollectionPanel } from '../collection-panel/collection-panel';
-import { loadCollection, loadCollectionTags } from '~/store/collection-panel/collection-panel-action';
-import { getCollectionUrl } from '~/models/collection';
-
-import { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions';
-import { COLLECTION_CREATE_FORM_NAME, openCollectionCreateDialog } from '~/store/collections/collection-create-actions';
-import { CreateCollectionDialog } from '~/views-components/dialog-forms/create-collection-dialog';
-import { UpdateCollectionDialog } from '~/views-components/dialog-forms/update-collection-dialog';
-import { CreateProjectDialog } from '~/views-components/dialog-forms/create-project-dialog';
-import { UpdateProjectDialog } from '~/views-components/dialog-forms/update-project-dialog';
-
-import { ProjectPanel } from "~/views/project-panel/project-panel";
 import { AuthService } from "~/services/auth-service/auth-service";
 import { RenameFileDialog } from '~/views-components/rename-file-dialog/rename-file-dialog';
 import { FileRemoveDialog } from '~/views-components/file-remove-dialog/file-remove-dialog';
 import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog/multiple-files-remove-dialog';
 import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog';
 import { CollectionPartialCopyDialog } from '~/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog';
+import { SidePanel } from '~/views-components/side-panel/side-panel';
+import { Routes } from '~/routes/routes';
+import { Breadcrumbs } from '~/views-components/breadcrumbs/breadcrumbs';
+import { CreateProjectDialog } from '~/views-components/dialog-forms/create-project-dialog';
+import { CreateCollectionDialog } from '~/views-components/dialog-forms/create-collection-dialog';
+import { CopyCollectionDialog } from '~/views-components/dialog-forms/copy-collection-dialog';
+import { UpdateCollectionDialog } from '~/views-components/dialog-forms/update-collection-dialog';
+import { UpdateProjectDialog } from '~/views-components/dialog-forms/update-project-dialog';
 import { MoveProjectDialog } from '~/views-components/dialog-forms/move-project-dialog';
 import { MoveCollectionDialog } from '~/views-components/dialog-forms/move-collection-dialog';
-import { CopyCollectionDialog } from '~/views-components/dialog-forms/copy-collection-dialog';
+import { ProcessPanel } from '~/views/process-panel/process-panel';
 
-const DRAWER_WITDH = 240;
 const APP_BAR_HEIGHT = 100;
 
-type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar';
+type CssRules = 'root' | 'appBar' | 'content' | 'contentWrapper';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -76,12 +57,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         position: "absolute",
         width: "100%"
     },
-    drawerPaper: {
-        position: 'relative',
-        width: DRAWER_WITDH,
-        display: 'flex',
-        flexDirection: 'column',
-    },
     contentWrapper: {
         backgroundColor: theme.palette.background.default,
         display: "flex",
@@ -95,15 +70,11 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         flexGrow: 1,
         position: 'relative'
     },
-    toolbar: theme.mixins.toolbar
 });
 
 interface WorkbenchDataProps {
-    projects: Array<TreeItem<ProjectResource>>;
-    currentProjectId: string;
     user?: User;
     currentToken?: string;
-    sidePanelItems: SidePanelItem[];
 }
 
 interface WorkbenchGeneralProps {
@@ -116,10 +87,6 @@ interface WorkbenchActionProps {
 
 type WorkbenchProps = WorkbenchDataProps & WorkbenchGeneralProps & WorkbenchActionProps & DispatchProp<any> & WithStyles<CssRules>;
 
-interface NavBreadcrumb extends Breadcrumb {
-    itemId: string;
-}
-
 interface NavMenuItem extends MainAppBarMenuItem {
     action: () => void;
 }
@@ -138,16 +105,12 @@ interface WorkbenchState {
 export const Workbench = withStyles(styles)(
     connect<WorkbenchDataProps>(
         (state: RootState) => ({
-            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: "",
@@ -183,63 +146,26 @@ export const Workbench = withStyles(styles)(
             };
 
             render() {
-                const path = getTreePath(this.props.projects, this.props.currentProjectId);
-                const breadcrumbs = path.map(item => ({
-                    label: item.data.name,
-                    itemId: item.data.uuid,
-                    status: item.status
-                }));
-
                 const { classes, user } = this.props;
                 return (
                     <div className={classes.root}>
                         <div className={classes.appBar}>
                             <MainAppBar
-                                breadcrumbs={breadcrumbs}
+                                breadcrumbs={Breadcrumbs}
                                 searchText={this.state.searchText}
                                 user={this.props.user}
                                 menuItems={this.state.menuItems}
                                 buildInfo={this.props.buildInfo}
                                 {...this.mainAppBarActions} />
                         </div>
-                        {user &&
-                            <Drawer
-                                variant="permanent"
-                                classes={{
-                                    paper: classes.drawerPaper,
-                                }}>
-                                <div className={classes.toolbar} />
-                                <SidePanel
-                                    toggleOpen={this.toggleSidePanelOpen}
-                                    toggleActive={this.toggleSidePanelActive}
-                                    sidePanelItems={this.props.sidePanelItems}
-                                    onContextMenu={(event) => this.openContextMenu(event, {
-                                        uuid: this.props.authService.getUuid() || "",
-                                        name: "",
-                                        kind: ContextMenuKind.ROOT_PROJECT
-                                    })}>
-                                    <ProjectTree
-                                        projects={this.props.projects}
-                                        toggleOpen={itemId => this.props.dispatch(setProjectItem(itemId, ItemMode.OPEN))}
-                                        onContextMenu={(event, item) => this.openContextMenu(event, {
-                                            uuid: item.data.uuid,
-                                            name: item.data.name,
-                                            kind: ContextMenuKind.PROJECT
-                                        })}
-                                        toggleActive={itemId => {
-                                            this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE));
-                                            this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT));
-                                        }} />
-                                </SidePanel>
-                            </Drawer>}
+                        {user && <SidePanel />}
                         <main className={classes.contentWrapper}>
                             <div className={classes.content}>
                                 <Switch>
-                                    <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`} />} />
-                                    <Route path="/projects/:id" render={this.renderProjectPanel} />
-                                    <Route path="/favorites" render={this.renderFavoritePanel} />
-                                    <Route path="/collections/:id" render={this.renderCollectionPanel} />
-                                    <Route path="/process/:id" render={this.renderProcessPanel} />
+                                    <Route path={Routes.PROJECTS} component={ProjectPanel} />
+                                    <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
+                                    <Route path={Routes.FAVORITES} component={FavoritePanel} />
+                                    <Route path={Routes.PROCESS} component={ProcessPanel} />
                                 </Switch>
                             </div>
                             {user && <DetailsPanel />}
@@ -266,102 +192,7 @@ export const Workbench = withStyles(styles)(
                 );
             }
 
-            renderProcessPanel = (props: RouteComponentProps<{ id: string }>) => <ProcessPanel
-                onContextMenu={(event, item) => {
-                    this.openContextMenu(event, {
-                        uuid: 'item.uuid',
-                        name: 'item.name',
-                        description: 'item.description',
-                        kind: ContextMenuKind.PROCESS
-                    });
-                }}
-                {...props} />
-
-            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
-                onItemRouteChange={(collectionId) => {
-                    this.props.dispatch<any>(loadCollection(collectionId));
-                    this.props.dispatch<any>(loadCollectionTags(collectionId));
-                }}
-                onContextMenu={(event, item) => {
-                    this.openContextMenu(event, {
-                        uuid: item.uuid,
-                        name: item.name,
-                        description: item.description,
-                        kind: ContextMenuKind.COLLECTION
-                    });
-                }}
-                {...props} />
-
-            renderProjectPanel = (props: RouteComponentProps<{ id: string }>) => <ProjectPanel
-                onItemRouteChange={itemId => this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE))}
-                onContextMenu={(event, item) => {
-                    let kind: ContextMenuKind;
-
-                    if (item.kind === ResourceKind.PROJECT) {
-                        kind = ContextMenuKind.PROJECT;
-                    } else if (item.kind === ResourceKind.COLLECTION) {
-                        kind = ContextMenuKind.COLLECTION_RESOURCE;
-                    } else {
-                        kind = ContextMenuKind.RESOURCE;
-                    }
-
-                    this.openContextMenu(event, {
-                        uuid: item.uuid,
-                        name: item.name,
-                        description: item.description,
-                        kind
-                    });
-                }}
-                onProjectCreationDialogOpen={this.handleProjectCreationDialogOpen}
-                onCollectionCreationDialogOpen={this.handleCollectionCreationDialogOpen}
-                onItemClick={item => {
-                    this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
-                }}
-                onItemDoubleClick={item => {
-                    switch (item.kind) {
-                        case ResourceKind.COLLECTION:
-                            this.props.dispatch(loadCollection(item.uuid));
-                            this.props.dispatch(push(getCollectionUrl(item.uuid)));
-                        default:
-                            this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE));
-                            this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
-                    }
-
-                }}
-                {...props} />
-
-            renderFavoritePanel = (props: RouteComponentProps<{ id: string }>) => <FavoritePanel
-                onItemRouteChange={() => this.props.dispatch(favoritePanelActions.REQUEST_ITEMS())}
-                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.handleProjectCreationDialogOpen}
-                onItemClick={item => {
-                    this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
-                }}
-                onItemDoubleClick={item => {
-                    switch (item.kind) {
-                        case ResourceKind.COLLECTION:
-                            this.props.dispatch(loadCollection(item.uuid));
-                            this.props.dispatch(push(getCollectionUrl(item.uuid)));
-                        default:
-                            this.props.dispatch(loadDetails(item.uuid, ResourceKind.PROJECT));
-                            this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE));
-                    }
-
-                }}
-                {...props} />
-
             mainAppBarActions: MainAppBarActionProps = {
-                onBreadcrumbClick: ({ itemId }: NavBreadcrumb) => {
-                    this.props.dispatch(setProjectItem(itemId, ItemMode.BOTH));
-                    this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT));
-                },
                 onSearch: searchText => {
                     this.setState({ searchText });
                     this.props.dispatch(push(`/search?q=${searchText}`));
@@ -370,48 +201,8 @@ export const Workbench = withStyles(styles)(
                 onDetailsPanelToggle: () => {
                     this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
                 },
-                onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: NavBreadcrumb) => {
-                    this.openContextMenu(event, {
-                        uuid: breadcrumb.itemId,
-                        name: breadcrumb.label,
-                        kind: ContextMenuKind.PROJECT
-                    });
-                }
             };
 
-            toggleSidePanelOpen = (itemId: string) => {
-                this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(itemId));
-            }
-
-            toggleSidePanelActive = (itemId: string) => {
-                this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
-
-                const panelItem = this.props.sidePanelItems.find(it => it.id === itemId);
-                if (panelItem && panelItem.activeAction) {
-                    panelItem.activeAction(this.props.dispatch, this.props.authService.getUuid());
-                }
-            }
-
-            handleProjectCreationDialogOpen = (itemUuid: string) => {
-                this.props.dispatch(reset(PROJECT_CREATE_FORM_NAME));
-                this.props.dispatch<any>(openProjectCreateDialog(itemUuid));
-            }
-
-            handleCollectionCreationDialogOpen = (itemUuid: string) => {
-                this.props.dispatch(reset(COLLECTION_CREATE_FORM_NAME));
-                this.props.dispatch<any>(openCollectionCreateDialog(itemUuid));
-            }
-
-            openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) => {
-                event.preventDefault();
-                this.props.dispatch(
-                    contextMenuActions.OPEN_CONTEXT_MENU({
-                        position: { x: event.clientX, y: event.clientY },
-                        resource
-                    })
-                );
-            }
-
             toggleCurrentTokenModal = () => {
                 this.setState({ isCurrentTokenDialogOpen: !this.state.isCurrentTokenDialogOpen });
             }