refs #master Merge branch 'origin/master' into 13828-trash-view
authorDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 30 Aug 2018 17:59:36 +0000 (19:59 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 30 Aug 2018 17:59:36 +0000 (19:59 +0200)
# Conflicts:
# package.json
# src/models/container-request.ts
# src/models/resource.ts
# src/services/collection-service/collection-service.ts
# src/services/services.ts
# src/store/navigation/navigation-action.ts
# src/store/project/project-action.ts
# src/store/project/project-reducer.test.ts
# src/store/project/project-reducer.ts
# src/store/side-panel/side-panel-reducer.ts
# src/store/store.ts
# src/views-components/context-menu/action-sets/collection-action-set.ts
# src/views-components/context-menu/action-sets/collection-resource-action-set.ts
# src/views-components/context-menu/action-sets/project-action-set.ts
# src/views/favorite-panel/favorite-panel-item.ts
# src/views/project-panel/project-panel-item.ts
# src/views/workbench/workbench.tsx

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

166 files changed:
.env
package.json
src/common/api/common-resource-service.ts
src/common/config.ts
src/common/custom-theme.ts
src/common/file.ts [new file with mode: 0644]
src/common/unionize.ts [new file with mode: 0644]
src/common/webdav.test.ts
src/common/webdav.ts
src/components/breadcrumbs/breadcrumbs.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/data-explorer/data-explorer.test.tsx
src/components/data-table/data-column.ts
src/components/data-table/data-table.test.tsx
src/components/details-attribute/details-attribute.tsx
src/components/file-upload-dialog/file-upload-dialog.tsx [new file with mode: 0644]
src/components/file-upload/file-upload.tsx
src/components/form-dialog/form-dialog.tsx [new file with mode: 0644]
src/components/list-item-text-icon/list-item-text-icon.tsx
src/components/side-panel/side-panel.tsx [deleted file]
src/components/subprocess-filter/subprocess-filter.tsx [new file with mode: 0644]
src/components/tree/tree.test.tsx
src/components/tree/tree.tsx
src/index.tsx
src/models/container.ts [new file with mode: 0644]
src/models/mount-types.ts [new file with mode: 0644]
src/models/resource.ts
src/models/runtime-constraints.ts [new file with mode: 0644]
src/models/scheduling-parameters.ts [new file with mode: 0644]
src/models/tree.test.ts
src/models/tree.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/collection-files-service/collection-manifest-mapper.ts
src/services/collection-service/collection-service-files-response.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/container-request-service/container-request-service.ts [new file with mode: 0644]
src/services/container-service/container-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/auth/auth-actions.test.ts
src/store/auth/auth-reducer.test.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/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
src/store/collections/collection-copy-actions.ts [new file with mode: 0644]
src/store/collections/collection-create-actions.ts [new file with mode: 0644]
src/store/collections/collection-move-actions.ts [new file with mode: 0644]
src/store/collections/collection-partial-copy-actions.ts [new file with mode: 0644]
src/store/collections/collection-update-actions.ts [new file with mode: 0644]
src/store/collections/collection-upload-actions.ts [new file with mode: 0644]
src/store/collections/collections-reducer.ts [deleted file]
src/store/collections/creator/collection-creator-action.ts [deleted file]
src/store/collections/creator/collection-creator-reducer.test.ts [deleted file]
src/store/collections/creator/collection-creator-reducer.ts [deleted file]
src/store/collections/updater/collection-updater-action.ts [deleted file]
src/store/collections/updater/collection-updater-reducer.ts [deleted file]
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/data-explorer/data-explorer-reducer.test.tsx
src/store/details-panel/details-panel-action.ts
src/store/details-panel/details-panel-reducer.ts
src/store/dialog/dialog-actions.ts
src/store/dialog/dialog-reducer.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/file-uploader/file-uploader-actions.ts [moved from src/store/collections/uploader/collection-uploader-actions.ts with 61% similarity]
src/store/file-uploader/file-uploader-reducer.ts [moved from src/store/collections/uploader/collection-uploader-reducer.ts with 69% similarity]
src/store/move-to-dialog/move-to-dialog.ts [new file with mode: 0644]
src/store/navigation/navigation-action.ts
src/store/processes/process.ts [new file with mode: 0644]
src/store/processes/processes-actions.ts [new file with mode: 0644]
src/store/project-panel/project-panel-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/project-tree-picker/project-tree-picker-actions.ts [new file with mode: 0644]
src/store/project/project-reducer.test.ts [deleted file]
src/store/project/project-reducer.ts [deleted file]
src/store/projects/project-create-actions.ts [new file with mode: 0644]
src/store/projects/project-move-actions.ts [new file with mode: 0644]
src/store/projects/project-update-actions.ts [new file with mode: 0644]
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.test.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/validators/validators.tsx
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/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-files-action-set.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/action-sets/collection-resource-action-set.ts
src/views-components/context-menu/action-sets/process-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/root-project-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected.tsx [deleted file]
src/views-components/create-collection-dialog/create-collection-dialog.tsx [deleted file]
src/views-components/create-project-dialog/create-project-dialog.tsx [deleted file]
src/views-components/data-explorer/data-explorer.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/collection-details.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/process-details.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/dialog-copy/dialog-collection-copy.tsx [new file with mode: 0644]
src/views-components/dialog-copy/dialog-collection-partial-copy.tsx [new file with mode: 0644]
src/views-components/dialog-create/dialog-collection-create-selected.tsx [deleted file]
src/views-components/dialog-create/dialog-collection-create.tsx
src/views-components/dialog-create/dialog-project-create.tsx
src/views-components/dialog-forms/copy-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/create-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/create-project-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/files-upload-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/move-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/move-project-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-copy-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/update-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/update-project-dialog.ts [new file with mode: 0644]
src/views-components/dialog-move/dialog-move-to.tsx [new file with mode: 0644]
src/views-components/dialog-update/dialog-collection-update.tsx
src/views-components/dialog-update/dialog-project-update.tsx
src/views-components/dialog-upload/dialog-collection-files-upload.tsx [new file with mode: 0644]
src/views-components/file-uploader/file-uploader.tsx [new file with mode: 0644]
src/views-components/form-fields/collection-form-fields.tsx [new file with mode: 0644]
src/views-components/form-fields/project-form-fields.tsx [new file with mode: 0644]
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/project-tree/project-tree.test.tsx
src/views-components/rename-file-dialog/rename-file-dialog.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-components/update-collection-dialog/update-collection-dialog..tsx [deleted file]
src/views-components/update-project-dialog/update-project-dialog.tsx [deleted file]
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-information-card.tsx [new file with mode: 0644]
src/views/process-panel/process-panel-root.tsx [new file with mode: 0644]
src/views/process-panel/process-panel.tsx [new file with mode: 0644]
src/views/process-panel/subprocesses-card.tsx [new file with mode: 0644]
src/views/project-panel/project-panel-item.ts [deleted file]
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.tsx
yarn.lock

diff --git a/.env b/.env
index df56fb28a76b3fbd7d61e647a59af98a3b2e7308..ed397c5cc0952badd931be8ccfdd7ec60d95a6d1 100644 (file)
--- a/.env
+++ b/.env
@@ -4,5 +4,4 @@
 
 REACT_APP_ARVADOS_CONFIG_URL=/config.json
 REACT_APP_ARVADOS_API_HOST=qr1hi.arvadosapi.com
-REACT_APP_ARVADOS_KEEP_WEB_HOST=collections.qr1hi.arvadosapi.com
 HTTPS=true
\ No newline at end of file
index 0264998b68e09133be53ef28dda8d4d90c0b7222..0e6435ebf536da29d695f224423f56780f4fca40 100644 (file)
@@ -3,19 +3,19 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@material-ui/core": "1.4.2",
-    "@material-ui/icons": "2.0.0",
+    "@material-ui/core": "1.5.0",
+    "@material-ui/icons": "2.0.2",
     "@types/lodash": "4.14.116",
-    "@types/react-copy-to-clipboard": "4.2.5",
-    "@types/react-dropzone": "4.2.1",
-    "@types/redux-form": "7.4.4",
+    "@types/react-copy-to-clipboard": "4.2.6",
+    "@types/react-dropzone": "4.2.2",
+    "@types/redux-form": "7.4.5",
     "axios": "0.18.0",
     "classnames": "2.2.6",
     "lodash": "4.17.10",
     "react": "16.4.2",
     "react-copy-to-clipboard": "5.0.1",
     "react-dom": "16.4.2",
-    "react-dropzone": "4.2.13",
+    "react-dropzone": "5.0.1",
     "react-redux": "5.0.7",
     "react-router": "4.3.1",
     "react-router-dom": "4.3.1",
@@ -26,7 +26,7 @@
     "unionize": "2.1.2"
   },
   "scripts": {
-    "start": "REACT_APP_BUILD_NUMBER=$BUILD_NUMBER REACT_APP_GIT_COMMIT=$GIT_COMMIT react-scripts-ts start",
+    "start": "react-scripts-ts start",
     "build": "REACT_APP_BUILD_NUMBER=$BUILD_NUMBER REACT_APP_GIT_COMMIT=$GIT_COMMIT react-scripts-ts build",
     "test": "react-scripts-ts test --env=jsdom",
     "eject": "react-scripts-ts eject",
   },
   "devDependencies": {
     "@types/classnames": "^2.2.4",
-    "@types/enzyme": "3.1.12",
-    "@types/enzyme-adapter-react-16": "1.0.2",
+    "@types/enzyme": "3.1.13",
+    "@types/enzyme-adapter-react-16": "1.0.3",
     "@types/jest": "23.3.1",
-    "@types/node": "10.5.5",
+    "@types/node": "10.7.1",
     "@types/react": "16.4",
-    "@types/react-dom": "16.0.6",
+    "@types/react-dom": "16.0.7",
     "@types/react-redux": "6.0.6",
     "@types/react-router": "4.0.29",
     "@types/react-router-dom": "4.3.0",
     "@types/react-router-redux": "5.0.15",
     "@types/redux-devtools": "3.0.44",
-    "@types/redux-form": "7.4.4",
+    "@types/redux-form": "7.4.5",
     "axios-mock-adapter": "1.15.0",
-    "enzyme": "3.3.0",
-    "enzyme-adapter-react-16": "1.1.1",
+    "enzyme": "3.4.4",
+    "enzyme-adapter-react-16": "1.2.0",
     "jest-localstorage-mock": "2.2.0",
     "redux-devtools": "3.4.1",
     "redux-form": "7.4.2",
index caa4d760c9e08ef1c7af63bf86e90b34a03d4ebb..2c9bfb51679f16bef721b4499f0cf00181ddc3f4 100644 (file)
@@ -29,6 +29,13 @@ export interface Errors {
     errorToken: string;
 }
 
+export enum CommonResourceServiceError {
+    UNIQUE_VIOLATION = 'UniqueViolation',
+    OWNERSHIP_CYCLE = 'OwnershipCycle',
+    UNKNOWN = 'Unknown',
+    NONE = 'None'
+}
+
 export class CommonResourceService<T extends Resource> {
 
     static mapResponseKeys = (response: any): Promise<any> =>
@@ -98,11 +105,27 @@ export class CommonResourceService<T extends Resource> {
                 }));
     }
 
-    update(uuid: string, data: any) {
+    update(uuid: string, data: Partial<T>) {
         return CommonResourceService.defaultResponse(
             this.serverApi
-                .put<T>(this.resourceType + uuid, data));
+                .put<T>(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data)));
 
     }
 }
 
+export const getCommonResourceServiceError = (errorResponse: any) => {
+    if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /UniqueViolation/.test(error):
+                return CommonResourceServiceError.UNIQUE_VIOLATION;
+            case /ownership cycle/.test(error):
+                return CommonResourceServiceError.OWNERSHIP_CYCLE;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
+
+
index 759a20158f9f8cafac459cd4ee2213f36c00890d..061f9c00af0b91e5b1a92e5337ecda44492c2754 100644 (file)
@@ -7,8 +7,48 @@ import Axios from "axios";
 export const CONFIG_URL = process.env.REACT_APP_ARVADOS_CONFIG_URL || "/config.json";
 
 export interface Config {
-    apiHost: string;
-    keepWebHost: string;
+    auth: {};
+    basePath: string;
+    baseUrl: string;
+    batchPath: string;
+    blobSignatureTtl: number;
+    crunchLimitLogBytesPerJob: number;
+    crunchLogBytesPerEvent: number;
+    crunchLogPartialLineThrottlePeriod: number;
+    crunchLogSecondsBetweenEvents: number;
+    crunchLogThrottleBytes: number;
+    crunchLogThrottleLines: number;
+    crunchLogThrottlePeriod: number;
+    defaultCollectionReplication: number;
+    defaultTrashLifetime: number;
+    description: string;
+    discoveryVersion: string;
+    dockerImageFormats: string[];
+    documentationLink: string;
+    generatedAt: string;
+    gitUrl: string;
+    id: string;
+    keepWebServiceUrl: string;
+    kind: string;
+    maxRequestSize: number;
+    name: string;
+    packageVersion: string;
+    parameters: {};
+    protocol: string;
+    remoteHosts: string;
+    remoteHostsViaDNS: boolean;
+    resources: {};
+    revision: string;
+    rootUrl: string;
+    schemas: {};
+    servicePath: string;
+    sourceVersion: string;
+    source_version: string;
+    title: string;
+    uuidPrefix: string;
+    version: string;
+    websocketUrl: string;
+    workbenchUrl: string;
 }
 
 export const fetchConfig = () => {
@@ -16,22 +56,62 @@ export const fetchConfig = () => {
         .get<ConfigJSON>(CONFIG_URL + "?nocache=" + (new Date()).getTime())
         .then(response => response.data)
         .catch(() => Promise.resolve(getDefaultConfig()))
-        .then(mapConfig);
+        .then(config => Axios.get<Config>(getDiscoveryURL(config.API_HOST)))
+        .then(response => response.data);
 };
 
+export const mockConfig = (config: Partial<Config>): Config => ({
+    auth: {},
+    basePath: '',
+    baseUrl: '',
+    batchPath: '',
+    blobSignatureTtl: 0,
+    crunchLimitLogBytesPerJob: 0,
+    crunchLogBytesPerEvent: 0,
+    crunchLogPartialLineThrottlePeriod: 0,
+    crunchLogSecondsBetweenEvents: 0,
+    crunchLogThrottleBytes: 0,
+    crunchLogThrottleLines: 0,
+    crunchLogThrottlePeriod: 0,
+    defaultCollectionReplication: 0,
+    defaultTrashLifetime: 0,
+    description: '',
+    discoveryVersion: '',
+    dockerImageFormats: [],
+    documentationLink: '',
+    generatedAt: '',
+    gitUrl: '',
+    id: '',
+    keepWebServiceUrl: '',
+    kind: '',
+    maxRequestSize: 0,
+    name: '',
+    packageVersion: '',
+    parameters: {},
+    protocol: '',
+    remoteHosts: '',
+    remoteHostsViaDNS: false,
+    resources: {},
+    revision: '',
+    rootUrl: '',
+    schemas: {},
+    servicePath: '',
+    sourceVersion: '',
+    source_version: '',
+    title: '',
+    uuidPrefix: '',
+    version: '',
+    websocketUrl: '',
+    workbenchUrl: '',
+    ...config
+});
+
 interface ConfigJSON {
     API_HOST: string;
-    KEEP_WEB_HOST: string;
 }
 
-const mapConfig = (config: ConfigJSON): Config => ({
-    apiHost: addProtocol(config.API_HOST),
-    keepWebHost: addProtocol(config.KEEP_WEB_HOST)
-});
-
 const getDefaultConfig = (): ConfigJSON => ({
     API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "",
-    KEEP_WEB_HOST: process.env.REACT_APP_ARVADOS_KEEP_WEB_HOST || ""
 });
 
-const addProtocol = (url: string) => `${window.location.protocol}//${url}`;
+const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/discovery/v1/apis/arvados/v1/rest`;
index 098e0090cc90bc896cd795169ddb32e8582fb817..2b0c58918f11270ef786e7d5a99d06f8bd61e001 100644 (file)
@@ -99,6 +99,9 @@ const themeOptions: ArvadosThemeOptions = {
             }
         },
         MuiInput: {
+            root: {
+                fontSize: '0.875rem'
+            },
             underline: {
                 '&:after': {
                     borderBottomColor: purple800
@@ -109,6 +112,9 @@ const themeOptions: ArvadosThemeOptions = {
             }
         },
         MuiFormLabel: {
+            root: {
+                fontSize: '0.875rem'
+            },
             focused: {
                 "&$focused:not($error)": {
                     color: purple800
diff --git a/src/common/file.ts b/src/common/file.ts
new file mode 100644 (file)
index 0000000..2311399
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const fileToArrayBuffer = (file: File) =>
+    new Promise<ArrayBuffer>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+            resolve(reader.result as ArrayBuffer);
+        };
+        reader.onerror = () => {
+            reject();
+        };
+        reader.readAsArrayBuffer(file);
+    });
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 d96465ba7e61a748f92f3a9b53d78b4b057266fc..c85f30e793864ecc3cb5798c44a85de75afa6d55 100644 (file)
@@ -41,15 +41,13 @@ describe('WebDAV', () => {
 
     it('PUT', async () => {
         const { open, send, load, progress, createRequest } = mockCreateRequest();
-        const onProgress = jest.fn();
         const webdav = new WebDAV(undefined, createRequest);
-        const promise = webdav.put('foo', 'Test data', { onProgress });
+        const promise = webdav.put('foo', 'Test data');
         progress();
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PUT', 'foo');
         expect(send).toHaveBeenCalledWith('Test data');
-        expect(onProgress).toHaveBeenCalled();
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
index 57caebc839e433f29ee26e13c5163e71270241dc..27e1f22de5be8c642072d7ae42b80f9189fe524b 100644 (file)
@@ -58,8 +58,8 @@ export class WebDAV {
                 .keys(headers)
                 .forEach(key => r.setRequestHeader(key, headers[key]));
 
-            if (config.onProgress) {
-                r.addEventListener('progress', config.onProgress);
+            if (config.onUploadProgress) {
+                r.upload.addEventListener('progress', config.onUploadProgress);
             }
 
             r.addEventListener('load', () => resolve(r));
@@ -73,7 +73,7 @@ export interface WebDAVRequestConfig {
     headers?: {
         [key: string]: string;
     };
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
 
 interface WebDAVDefaults {
@@ -86,5 +86,5 @@ interface RequestConfig {
     url: string;
     headers?: { [key: string]: string };
     data?: any;
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
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;
index 665758c3e62cbd916366045f79c1e4026a560c99..f9c18219852fdfce3a5cf0f60a4d874cf56c06f1 100644 (file)
@@ -8,10 +8,6 @@ import { FileTreeData } from '../file-tree/file-tree-data';
 import { FileTree } from '../file-tree/file-tree';
 import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button } from '@material-ui/core';
 import { CustomizeTableIcon } from '../icon/icon';
-import { connect, DispatchProp } from "react-redux";
-import { Dispatch } from "redux";
-import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
 
 export interface CollectionPanelFilesProps {
     items: Array<TreeItem<FileTreeData>>;
@@ -40,44 +36,34 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-const renameFile = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-  services.collectionFilesService.renameTest();
-};
-
-
 export const CollectionPanelFiles =
-    connect()(
     withStyles(styles)(
-    ({ onItemMenuOpen, onOptionsMenuOpen, classes, dispatch, ...treeProps }: CollectionPanelFilesProps & DispatchProp & WithStyles<CssRules>) =>
-        <Card className={classes.root}>
-            <CardHeader
-                title="Files"
-                action={
-                    <Button onClick={
-                        () => {
-                            dispatch<any>(renameFile());
-                        }}
-                        variant='raised'
-                        color='primary'
-                        size='small'>
-                        Upload data
+        ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+            <Card className={classes.root}>
+                <CardHeader
+                    title="Files"
+                    action={
+                        <Button onClick={onUploadDataClick}
+                            variant='raised'
+                            color='primary'
+                            size='small'>
+                            Upload data
                     </Button>
-                } />
-            <CardHeader
-                className={classes.cardSubheader}
-                action={
-                    <IconButton onClick={onOptionsMenuOpen}>
-                        <CustomizeTableIcon />
-                    </IconButton>
-                } />
-            <Grid container justify="space-between">
-                <Typography variant="caption" className={classes.nameHeader}>
-                    Name
+                    } />
+                <CardHeader
+                    className={classes.cardSubheader}
+                    action={
+                        <IconButton onClick={onOptionsMenuOpen}>
+                            <CustomizeTableIcon />
+                        </IconButton>
+                    } />
+                <Grid container justify="space-between">
+                    <Typography variant="caption" className={classes.nameHeader}>
+                        Name
                     </Typography>
-                <Typography variant="caption" className={classes.fileSizeHeader}>
-                    File size
+                    <Typography variant="caption" className={classes.fileSizeHeader}>
+                        File size
                     </Typography>
-            </Grid>
-            <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
-        </Card>)
-);
+                </Grid>
+                <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
+            </Card>);
index a34ab1c8edfeaf97d4f01fb3a1ee51f8b200aad2..3e447b4015480091b66e6ee39f5b6d5560ea6cc6 100644 (file)
@@ -13,6 +13,7 @@ import { SearchInput } from "../search-input/search-input";
 import { TablePagination } from "@material-ui/core";
 import { ProjectIcon } from '../icon/icon';
 import { DefaultView } from '../default-view/default-view';
+import { SortDirection } from '../data-table/data-column';
 
 configure({ adapter: new Adapter() });
 
@@ -20,11 +21,13 @@ describe("<DataExplorer />", () => {
 
     it("communicates with <SearchInput/>", () => {
         const onSearch = jest.fn();
+        const onSetColumns = jest.fn();
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
             items={[{ name: "item 1" }]}
             searchValue="search value"
-            onSearch={onSearch} />);
+            onSearch={onSearch}
+            onSetColumns={onSetColumns} />);
         expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value");
         dataExplorer.find(SearchInput).prop("onSearch")("new value");
         expect(onSearch).toHaveBeenCalledWith("new value");
@@ -32,12 +35,14 @@ describe("<DataExplorer />", () => {
 
     it("communicates with <ColumnSelector/>", () => {
         const onColumnToggle = jest.fn();
-        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true }];
+        const onSetColumns = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: [] }];
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
             columns={columns}
             onColumnToggle={onColumnToggle}
-            items={[{ name: "item 1" }]} />);
+            items={[{ name: "item 1" }]}
+            onSetColumns={onSetColumns} />);
         expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
         dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
         expect(onColumnToggle).toHaveBeenCalledWith("columns");
@@ -47,7 +52,8 @@ describe("<DataExplorer />", () => {
         const onFiltersChange = jest.fn();
         const onSortToggle = jest.fn();
         const onRowClick = jest.fn();
-        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true }];
+        const onSetColumns = jest.fn();
+        const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: [] }];
         const items = [{ name: "item 1" }];
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
@@ -55,7 +61,8 @@ describe("<DataExplorer />", () => {
             items={items}
             onFiltersChange={onFiltersChange}
             onSortToggle={onSortToggle}
-            onRowClick={onRowClick} />);
+            onRowClick={onRowClick}
+            onSetColumns={onSetColumns} />);
         expect(dataExplorer.find(DataTable).prop("columns").slice(0, -1)).toEqual(columns);
         expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
         dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
@@ -70,7 +77,7 @@ describe("<DataExplorer />", () => {
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
             items={[]}
-        />);
+            onSetColumns={jest.fn()} />);
         expect(dataExplorer.find(DataTable)).toHaveLength(0);
         expect(dataExplorer.find(DefaultView)).toHaveLength(1);
     });
@@ -78,6 +85,7 @@ describe("<DataExplorer />", () => {
     it("communicates with <TablePagination/>", () => {
         const onChangePage = jest.fn();
         const onChangeRowsPerPage = jest.fn();
+        const onSetColumns = jest.fn();
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
             items={[{ name: "item 1" }]}
@@ -85,7 +93,7 @@ describe("<DataExplorer />", () => {
             rowsPerPage={50}
             onChangePage={onChangePage}
             onChangeRowsPerPage={onChangeRowsPerPage}
-        />);
+            onSetColumns={onSetColumns} />);
         expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
         expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
         dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
@@ -114,5 +122,6 @@ const mockDataExplorerProps = () => ({
     onChangeRowsPerPage: jest.fn(),
     onContextMenu: jest.fn(),
     defaultIcon: ProjectIcon,
+    onSetColumns: jest.fn(),
     defaultMessages: ['testing'],
 });
index d4e23ab5b5eb95afc9f68414639e8348b235f545..90e87a88b0a7225a9cbea0d7b842e5146a593d4c 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
 import * as React from "react";
+import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
 
 export interface DataColumn<T, F extends DataTableFilterItem = DataTableFilterItem> {
     key?: React.Key;
@@ -34,3 +34,14 @@ export const toggleSortDirection = <T>(column: DataColumn<T>): DataColumn<T> =>
 export const resetSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
     return column.sortDirection ? { ...column, sortDirection: SortDirection.NONE } : column;
 };
+
+export const createDataColumn = <T, F extends DataTableFilterItem>(dataColumn: Partial<DataColumn<T, F>>): DataColumn<T, F> => ({
+    key: '',
+    name: '',
+    selected: true,
+    configurable: true,
+    sortDirection: SortDirection.NONE,
+    filters: [],
+    render: () => React.createElement('span'),
+    ...dataColumn,
+});
index 77c7825bc135079dd85bacce2beb42ddd728b1d7..1201dcb07316a45117e0b5300484156d4d52c34f 100644 (file)
@@ -8,31 +8,31 @@ import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } f
 import * as Adapter from "enzyme-adapter-react-16";
 import { DataTable, DataColumns } from "./data-table";
 import { DataTableFilters } from "../data-table-filters/data-table-filters";
-import { SortDirection } from "./data-column";
+import { SortDirection, createDataColumn } from "./data-column";
 
 configure({ adapter: new Adapter() });
 
 describe("<DataTable />", () => {
     it("shows only selected columns", () => {
         const columns: DataColumns<string> = [
-            {
+            createDataColumn({
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
                 configurable: true
-            },
-            {
+            }),
+            createDataColumn({
                 name: "Column 2",
                 render: () => <span />,
                 selected: true,
                 configurable: true
-            },
-            {
+            }),
+            createDataColumn({
                 name: "Column 3",
                 render: () => <span />,
                 selected: false,
                 configurable: true
-            }
+            }),
         ];
         const dataTable = mount(<DataTable
             columns={columns}
@@ -47,12 +47,12 @@ describe("<DataTable />", () => {
 
     it("renders column name", () => {
         const columns: DataColumns<string> = [
-            {
+            createDataColumn({
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
                 configurable: true
-            }
+            }),
         ];
         const dataTable = mount(<DataTable
             columns={columns}
@@ -67,13 +67,13 @@ describe("<DataTable />", () => {
 
     it("uses renderHeader instead of name prop", () => {
         const columns: DataColumns<string> = [
-            {
+            createDataColumn({
                 name: "Column 1",
                 renderHeader: () => <span>Column Header</span>,
                 render: () => <span />,
                 selected: true,
                 configurable: true
-            }
+            }),
         ];
         const dataTable = mount(<DataTable
             columns={columns}
@@ -88,13 +88,13 @@ describe("<DataTable />", () => {
 
     it("passes column key prop to corresponding cells", () => {
         const columns: DataColumns<string> = [
-            {
+            createDataColumn({
                 name: "Column 1",
                 key: "column-1-key",
                 render: () => <span />,
                 selected: true,
                 configurable: true
-            }
+            })
         ];
         const dataTable = mount(<DataTable
             columns={columns}
@@ -110,18 +110,18 @@ describe("<DataTable />", () => {
 
     it("renders items", () => {
         const columns: DataColumns<string> = [
-            {
+            createDataColumn({
                 name: "Column 1",
                 render: (item) => <Typography>{item}</Typography>,
                 selected: true,
                 configurable: true
-            },
-            {
+            }),
+            createDataColumn({
                 name: "Column 2",
                 render: (item) => <Button>{item}</Button>,
                 selected: true,
                 configurable: true
-            }
+            })
         ];
         const dataTable = mount(<DataTable
             columns={columns}
@@ -136,13 +136,14 @@ describe("<DataTable />", () => {
     });
 
     it("passes sorting props to <TableSortLabel />", () => {
-        const columns: DataColumns<string> = [{
+        const columns: DataColumns<string> = [
+            createDataColumn({
             name: "Column 1",
             sortDirection: SortDirection.ASC,
             selected: true,
             configurable: true,
             render: (item) => <Typography>{item}</Typography>
-        }];
+        })];
         const onSortToggle = jest.fn();
         const dataTable = mount(<DataTable
             columns={columns}
@@ -157,6 +158,27 @@ describe("<DataTable />", () => {
         expect(onSortToggle).toHaveBeenCalledWith(columns[0]);
     });
 
+    it("does not display <DataTableFilter /> if there is no filters provided", () => {
+        const columns: DataColumns<string> = [{
+            name: "Column 1",
+            sortDirection: SortDirection.ASC,
+            selected: true,
+            configurable: true,
+            filters: [],
+            render: (item) => <Typography>{item}</Typography>
+        }];
+        const onFiltersChange = jest.fn();
+        const dataTable = mount(<DataTable
+            columns={columns}
+            items={[]}
+            onFiltersChange={onFiltersChange}
+            onRowClick={jest.fn()}
+            onRowDoubleClick={jest.fn()}
+            onSortToggle={jest.fn()}
+            onContextMenu={jest.fn()} />);
+        expect(dataTable.find(DataTableFilters)).toHaveLength(0);
+    });
+
     it("passes filter props to <DataTableFilter />", () => {
         const columns: DataColumns<string> = [{
             name: "Column 1",
index 3888b04b67d596ea84f3e11cbfeba30999adbc03..78b4341d173046972385cad95e584cb735138085 100644 (file)
@@ -8,7 +8,7 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import { ArvadosTheme } from '~/common/custom-theme';
 import * as classnames from "classnames";
 
-type CssRules = 'attribute' | 'label' | 'value' | 'link';
+type CssRules = 'attribute' | 'label' | 'value' | 'lowercaseValue' | 'link';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     attribute: {
@@ -17,28 +17,35 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         marginBottom: theme.spacing.unit
     },
     label: {
+        boxSizing: 'border-box',
         color: theme.palette.grey["500"],
         width: '40%'
     },
     value: {
+        boxSizing: 'border-box',
         width: '60%',
         display: 'flex',
         alignItems: 'flex-start',
         textTransform: 'capitalize'
     },
+    lowercaseValue: {
+        textTransform: 'lowercase'
+    },
     link: {
         width: '60%',
         color: theme.palette.primary.main,
         textDecoration: 'none',
-        overflowWrap: 'break-word'
+        overflowWrap: 'break-word',
+        cursor: 'pointer'
     }
 });
 
 interface DetailsAttributeDataProps {
     label: string;
     classLabel?: string;
-    value?: string | number;
+    value?: React.ReactNode;
     classValue?: string;
+    lowercaseValue?: boolean;
     link?: string;
     children?: React.ReactNode;
 }
@@ -46,12 +53,12 @@ interface DetailsAttributeDataProps {
 type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules>;
 
 export const DetailsAttribute = withStyles(styles)(
-    ({ label, link, value, children, classes, classLabel, classValue }: DetailsAttributeProps) =>
+    ({ label, link, value, children, classes, classLabel, classValue, lowercaseValue }: DetailsAttributeProps) =>
         <Typography component="div" className={classes.attribute}>
             <Typography component="span" className={classnames([classes.label, classLabel])}>{label}</Typography>
             { link
                 ? <a href={link} className={classes.link} target='_blank'>{value}</a>
-                : <Typography component="span" className={classnames([classes.value, classValue])}>
+                : <Typography component="span" className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
                     {value}
                     {children}
                 </Typography> }
diff --git a/src/components/file-upload-dialog/file-upload-dialog.tsx b/src/components/file-upload-dialog/file-upload-dialog.tsx
new file mode 100644 (file)
index 0000000..7215b6d
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { FileUpload } from "~/components/file-upload/file-upload";
+import { Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core/';
+import { Button, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { UploadFile } from '~/store/file-uploader/file-uploader-actions';
+
+export interface FilesUploadDialogProps {
+    files: UploadFile[];
+    uploading: boolean;
+    onSubmit: () => void;
+    onChange: (files: File[]) => void;
+}
+
+export const FilesUploadDialog = (props: FilesUploadDialogProps & WithDialogProps<{}>) =>
+    <Dialog open={props.open}
+        disableBackdropClick={true}
+        disableEscapeKeyDown={true}
+        fullWidth={true}
+        maxWidth='sm'>
+        <DialogTitle>Upload data</DialogTitle>
+        <DialogContent>
+            <FileUpload
+                files={props.files}
+                disabled={props.uploading}
+                onDrop={props.onChange}
+            />
+        </DialogContent>
+        <DialogActions>
+            <Button
+                variant='flat'
+                color='primary'
+                disabled={props.uploading}
+                onClick={props.closeDialog}>
+                Cancel
+            </Button>
+            <Button
+                variant='contained'
+                color='primary'
+                type='submit'
+                onClick={props.onSubmit}
+                disabled={props.uploading}>
+                {props.uploading
+                    ? <CircularProgress size={20} />
+                    : 'Upload data'}
+            </Button>
+        </DialogActions>
+    </Dialog>;
index 74efe009e9503a95576993985c776f9f2b396282..e7f402cdb247a900e66e3a9aa99676f3e8e2197d 100644 (file)
@@ -14,7 +14,7 @@ import { withStyles } from '@material-ui/core';
 import Dropzone from 'react-dropzone';
 import { CloudUploadIcon } from "../icon/icon";
 import { formatFileSize, formatProgress, formatUploadSpeed } from "~/common/formatters";
-import { UploadFile } from "~/store/collections/uploader/collection-uploader-actions";
+import { UploadFile } from '~/store/file-uploader/file-uploader-actions';
 
 type CssRules = "root" | "dropzone" | "container" | "uploadIcon";
 
@@ -36,7 +36,7 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-interface FileUploadProps {
+export interface FileUploadProps {
     files: UploadFile[];
     disabled: boolean;
     onDrop: (files: File[]) => void;
@@ -44,21 +44,17 @@ interface FileUploadProps {
 
 export const FileUpload = withStyles(styles)(
     ({ classes, files, disabled, onDrop }: FileUploadProps & WithStyles<CssRules>) =>
-    <Grid container direction={"column"}>
-        <Typography variant={"subheading"}>
-            Upload data
-        </Typography>
         <Dropzone className={classes.dropzone} onDrop={files => onDrop(files)} disabled={disabled}>
             {files.length === 0 &&
-            <Grid container justify="center" alignItems="center" className={classes.container}>
-                <Grid item component={"span"}>
-                    <Typography variant={"subheading"}>
-                        <CloudUploadIcon className={classes.uploadIcon}/> Drag and drop data or click to browse
+                <Grid container justify="center" alignItems="center" className={classes.container}>
+                    <Grid item component={"span"}>
+                        <Typography variant={"subheading"}>
+                            <CloudUploadIcon className={classes.uploadIcon} /> Drag and drop data or click to browse
                     </Typography>
-                </Grid>
-            </Grid>}
+                    </Grid>
+                </Grid>}
             {files.length > 0 &&
-                <Table style={{width: "100%"}}>
+                <Table style={{ width: "100%" }}>
                     <TableHead>
                         <TableRow>
                             <TableCell>File name</TableCell>
@@ -68,17 +64,16 @@ export const FileUpload = withStyles(styles)(
                         </TableRow>
                     </TableHead>
                     <TableBody>
-                    {files.map(f =>
-                        <TableRow key={f.id}>
-                            <TableCell>{f.file.name}</TableCell>
-                            <TableCell>{formatFileSize(f.file.size)}</TableCell>
-                            <TableCell>{formatUploadSpeed(f.prevLoaded, f.loaded, f.prevTime, f.currentTime)}</TableCell>
-                            <TableCell>{formatProgress(f.loaded, f.total)}</TableCell>
-                        </TableRow>
-                    )}
+                        {files.map(f =>
+                            <TableRow key={f.id}>
+                                <TableCell>{f.file.name}</TableCell>
+                                <TableCell>{formatFileSize(f.file.size)}</TableCell>
+                                <TableCell>{formatUploadSpeed(f.prevLoaded, f.loaded, f.prevTime, f.currentTime)}</TableCell>
+                                <TableCell>{formatProgress(f.loaded, f.total)}</TableCell>
+                            </TableRow>
+                        )}
                     </TableBody>
                 </Table>
             }
         </Dropzone>
-    </Grid>
 );
diff --git a/src/components/form-dialog/form-dialog.tsx b/src/components/form-dialog/form-dialog.tsx
new file mode 100644 (file)
index 0000000..150dc4b
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+
+type CssRules = "button" | "lastButton" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    lastButton: {
+        marginLeft: theme.spacing.unit,
+        marginRight: "20px",
+    },
+    formContainer: {
+        display: "flex",
+        flexDirection: "column",
+        marginTop: "20px",
+    },
+    dialogTitle: {
+        paddingBottom: "0"
+    },
+    progressIndicator: {
+        position: "absolute",
+        minWidth: "20px",
+    },
+    dialogActions: {
+        marginBottom: "24px"
+    }
+});
+
+interface DialogProjectProps {
+    cancelLabel?: string;
+    dialogTitle: string;
+    formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
+    submitLabel?: string;
+}
+
+export const FormDialog = withStyles(styles)((props: DialogProjectProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>) =>
+    <Dialog
+        open={props.open}
+        onClose={props.closeDialog}
+        disableBackdropClick={props.submitting}
+        disableEscapeKeyDown={props.submitting}
+        fullWidth
+        maxWidth='sm'>
+        <form>
+            <DialogTitle className={props.classes.dialogTitle}>
+                {props.dialogTitle}
+            </DialogTitle>
+            <DialogContent className={props.classes.formContainer}>
+                <props.formFields {...props} />
+            </DialogContent>
+            <DialogActions className={props.classes.dialogActions}>
+                <Button
+                    onClick={props.closeDialog}
+                    className={props.classes.button}
+                    color="primary"
+                    disabled={props.submitting}>
+                    {props.cancelLabel || 'Cancel'}
+                </Button>
+                <Button
+                    onClick={props.handleSubmit}
+                    className={props.classes.lastButton}
+                    color="primary"
+                    disabled={props.invalid || props.submitting || props.pristine}
+                    variant="contained">
+                    {props.submitLabel || 'Submit'}
+                    {props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
+                </Button>
+            </DialogActions>
+        </form>
+    </Dialog>
+);
+
+
index b34c6ab5f9cab8784e525b6356963f4fad4b800c..e7f63eafc0c1f7e9423e148daad623d008580623 100644 (file)
@@ -17,7 +17,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         alignItems: 'center'
     },
     listItemText: {
-        fontWeight: 700
+        fontWeight: 400
     },
     active: {
         color: theme.palette.primary.main,
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
-    }
-));
diff --git a/src/components/subprocess-filter/subprocess-filter.tsx b/src/components/subprocess-filter/subprocess-filter.tsx
new file mode 100644 (file)
index 0000000..58c33ee
--- /dev/null
@@ -0,0 +1,54 @@
+// 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 { Grid, Typography, Switch } from '@material-ui/core';
+
+type CssRules = 'grid' | 'label' | 'value' | 'switch';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    grid: {
+        display: 'flex'
+    },
+    label: {
+        width: '86px',
+        color: theme.palette.grey["500"],
+        textAlign: 'right'
+    },
+    value: {
+        width: '24px',
+        paddingLeft: theme.spacing.unit
+    },
+    switch: {
+        '& span:first-child': {
+            height: '18px'
+        }
+    }
+});
+
+export interface SubprocessFilterDataProps {
+    label: string;
+    value: number;
+    checked?: boolean;
+    key?: string;
+    onToggle?: () => void;
+}
+
+type SubprocessFilterProps = SubprocessFilterDataProps & WithStyles<CssRules>;
+
+export const SubprocessFilter = withStyles(styles)(
+    ({ classes, label, value, key, checked, onToggle }: SubprocessFilterProps) =>
+        <Grid item className={classes.grid} md={12} lg={6} >
+            <Typography component="span" className={classes.label}>{label}:</Typography>
+            <Typography component="span" className={classes.value}>{value}</Typography>
+            {onToggle && <Switch classes={{ root: classes.switch }}
+                checked={checked}
+                onChange={onToggle}
+                value={key}
+                color="primary" />
+            }
+        </Grid>
+);
\ No newline at end of file
index 45981d8962c7119f614b48a9ab866bceed9c65f9..8c3800269df1140a526af15344c41eac52ab966b 100644 (file)
@@ -7,7 +7,7 @@ import * as Enzyme from 'enzyme';
 import * as Adapter from 'enzyme-adapter-react-16';
 import ListItem from "@material-ui/core/ListItem/ListItem";
 
-import { Tree, TreeItem } from './tree';
+import { Tree, TreeItem, TreeItemStatus } from './tree';
 import { ProjectResource } from '../../models/project';
 import { mockProjectResource } from '../../models/test-utils';
 import { Checkbox } from '@material-ui/core';
@@ -22,7 +22,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED
         };
         const wrapper = mount(<Tree
             render={project => <div />}
@@ -39,7 +39,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED,
         };
         const wrapper = mount(<Tree
             render={project => <div />}
@@ -56,7 +56,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED
         };
         const wrapper = mount(<Tree
             showSelection={true}
@@ -74,7 +74,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED,
         };
         const spy = jest.fn();
         const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
@@ -92,7 +92,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED,
         });
     });
 
index 3e8cf904cfaae2274d1273bb81f1f77048b34c2a..c892d7d2c8fb12b86da3e4904b9dc9c8198148e1 100644 (file)
@@ -65,9 +65,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 export enum TreeItemStatus {
-    INITIAL,
-    PENDING,
-    LOADED
+    INITIAL = 'initial',
+    PENDING = 'pending',
+    LOADED = 'loaded'
 }
 
 export interface TreeItem<T> {
@@ -107,10 +107,10 @@ 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)}>
-                                    {it.status !== TreeItemStatus.INITIAL && it.items && it.items.length === 0 ? <span /> : <SidePanelRightArrowIcon />}
+                                    {this.getProperArrowAnimation(it.status, it.items!)}
                                 </ListItemIcon>
                             </i>
                             {this.props.showSelection &&
@@ -140,6 +140,16 @@ export const Tree = withStyles(styles)(
             </List>;
         }
 
+        getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
+            return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon />;
+        }
+
+        isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
+            return status === TreeItemStatus.PENDING ||
+                (status === TreeItemStatus.LOADED && !items) ||
+                (status === TreeItemStatus.LOADED && items && items.length === 0);
+        }
+
         getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
             const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
             return classnames(toggableIcon, {
@@ -161,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 443e76f3e62a42597d804aefd779b32b3f65ffa9..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';
@@ -27,9 +27,13 @@ import { collectionFilesActionSet } from './views-components/context-menu/action
 import { collectionFilesItemActionSet } from './views-components/context-menu/action-sets/collection-files-item-action-set';
 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.BUILD_NUMBER || "dev");
-const getGitCommit = () => "GIT-" + (process.env.GIT_COMMIT || "latest").substr(0, 7);
+const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev");
+const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7);
 const getBuildInfo = () => getBuildNumber() + " / " + getGitCommit();
 
 const buildInfo = getBuildInfo();
@@ -44,26 +48,28 @@ addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES_ITEM, collectionFilesItemActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSet);
+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>
@@ -73,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);
+        }
+    };
+};
+
 
diff --git a/src/models/container.ts b/src/models/container.ts
new file mode 100644 (file)
index 0000000..99cb309
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource, ResourceKind } from "./resource";
+import { MountType } from '~/models/mount-types';
+import { RuntimeConstraints } from "~/models/runtime-constraints";
+import { SchedulingParameters } from './scheduling-parameters';
+
+export enum ContainerState {
+    QUEUED = 'Queued',
+    LOCKED = 'Locked',
+    RUNNING = 'Running',
+    COMPLETE = 'Complete',
+    CANCELLED = 'Cancelled',
+}
+
+export interface ContainerResource extends Resource {
+    kind: ResourceKind.CONTAINER;
+    state: string;
+    startedAt: string | null;
+    finishedAt: string | null;
+    log: string | null;
+    environment: {};
+    cwd: string;
+    command: string[];
+    outputPath: string;
+    mounts: MountType[];
+    runtimeConstraints: RuntimeConstraints;
+    schedulingParameters: SchedulingParameters;
+    output: string | null;
+    containerImage: string;
+    progress: number;
+    priority: number;
+    exitCode: number | null;
+    authUuid: string | null;
+    lockedByUuid: string | null;
+}
diff --git a/src/models/mount-types.ts b/src/models/mount-types.ts
new file mode 100644 (file)
index 0000000..ec48c85
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum MountKind {
+    COLLECTION = 'collection',
+    GIT_TREE = 'git_tree',
+    TEMPORARY_DIRECTORY = 'tmp',
+    KEEP = 'keep',
+    MOUNTED_FILE = 'file',
+    JSON = 'JSON'
+}
+
+export type MountType =
+    CollectionMount |
+    GitTreeMount |
+    TemporaryDirectoryMount |
+    KeepMount |
+    JSONMount;
+
+export interface CollectionMount {
+    kind: MountKind.COLLECTION;
+    uuid?: string;
+    portableDataHash?: string;
+    path?: string;
+    writable?: boolean;
+}
+
+export interface GitTreeMount {
+    kind: MountKind.GIT_TREE;
+    uuid: string;
+    commit: string;
+    path?: string;
+}
+
+export enum TemporaryDirectoryDeviceType {
+    RAM = 'ram',
+    SSD = 'ssd',
+    DISK = 'disk',
+    NETWORK = 'network',
+}
+
+export interface TemporaryDirectoryMount {
+    kind: MountKind.TEMPORARY_DIRECTORY;
+    capacity: number;
+    deviceType: TemporaryDirectoryDeviceType;
+}
+
+export interface KeepMount {
+    kind: MountKind.KEEP;
+}
+
+export interface JSONMount {
+    kind: MountKind.JSON;
+    content: string;
+}
index ab487da070c29c5a30632266477c38959d548012..aff1b2417d6fe06a04b9ada5e1bf5fbb31282876 100644 (file)
@@ -22,8 +22,50 @@ export interface TrashResource extends Resource {
 
 export enum ResourceKind {
     COLLECTION = "arvados#collection",
+    CONTAINER = "arvados#container",
+    CONTAINER_REQUEST = "arvados#containerRequest",
     GROUP = "arvados#group",
     PROCESS = "arvados#containerRequest",
     PROJECT = "arvados#group",
-    WORKFLOW = "arvados#workflow"
+    USER = "arvados#user",
+    WORKFLOW = "arvados#workflow",
 }
+
+export enum ResourceObjectType {
+    COLLECTION = '4zz18',
+    CONTAINER = 'dz642',
+    CONTAINER_REQUEST = 'xvhdp',
+    GROUP = 'j7d0g',
+    USER = 'tpzed',
+}
+
+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;
+        case ResourceObjectType.CONTAINER_REQUEST:
+            return ResourceKind.CONTAINER_REQUEST;
+        case ResourceObjectType.CONTAINER:
+            return ResourceKind.CONTAINER;
+        default:
+            return undefined;
+    }
+};
diff --git a/src/models/runtime-constraints.ts b/src/models/runtime-constraints.ts
new file mode 100644 (file)
index 0000000..ba90537
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface RuntimeConstraints {
+    ram: number;
+    vcpus: number;
+    keepCacheRam: number;
+    API: boolean;
+}
diff --git a/src/models/scheduling-parameters.ts b/src/models/scheduling-parameters.ts
new file mode 100644 (file)
index 0000000..62f7224
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface SchedulingParameters {
+    partitions: string[];
+    preemptible: boolean;
+    maxRunTime: number;
+}
index 708cf4045c75b73fae7650b8a3bba789a46362bc..375a012054f9bea3a26a7bd8033642b44697ea24 100644 (file)
@@ -30,7 +30,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3', parent: 'Node 2', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeAncestors('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+        expect(Tree.getNodeAncestorsIds('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
     });
 
     it('gets node descendants', () => {
@@ -41,7 +41,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets root descendants', () => {
@@ -52,7 +52,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets node children', () => {
@@ -63,7 +63,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
     });
 
     it('gets root children', () => {
@@ -74,7 +74,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: '', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('')(newTree)).toEqual(['Node 1', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('')(newTree)).toEqual(['Node 1', 'Node 3']);
     });
 
     it('maps tree', () => {
index 8b66e50da7961df58c5c60f0b8fd23556f749467..a5fb49cff4a3adb3945cdcfbcb1a1ec6b4ac5080 100644 (file)
@@ -6,7 +6,7 @@ export type Tree<T> = Record<string, TreeNode<T>>;
 
 export const TREE_ROOT_ID = '';
 
-export interface TreeNode<T> {
+export interface TreeNode<T = any> {
     children: string[];
     value: T;
     id: string;
@@ -21,7 +21,7 @@ export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
     const [newTree] = [tree]
         .map(tree => getNode(node.id)(tree) === node
             ? tree
-            : {...tree, [node.id]: node})
+            : { ...tree, [node.id]: node })
         .map(addChild(node.parent, node.id));
     return newTree;
 };
@@ -46,25 +46,32 @@ export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (
 };
 
 export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapNodeValue(mapFn))
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const mapTree = <T, R>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+export const mapTree = <T, R = T>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapFn)
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>): string[] => {
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeAncestorsIds(id)(tree))(tree);
+
+
+export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     return node && node.parent
-        ? [...getNodeAncestors(node.parent)(tree), node.parent]
+        ? [...getNodeAncestorsIds(node.parent)(tree), node.parent]
         : [];
 };
 
-export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
+
+export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     const children = node ? node.children :
         id === TREE_ROOT_ID
@@ -75,12 +82,18 @@ export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tr
         .concat(limit < 1
             ? []
             : children
-                .map(id => getNodeDescendants(id, limit - 1)(tree))
+                .map(id => getNodeDescendantsIds(id, limit - 1)(tree))
                 .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
 };
 
-export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
-    getNodeDescendants(id, 0)(tree);
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeChildrenIds(id)(tree))(tree);
+
+export const getNodeChildrenIds = (id: string) => <T>(tree: Tree<T>): string[] =>
+    getNodeDescendantsIds(id, 0)(tree);
+
+export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
+    ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
 
 const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
     ({ ...node, value: mapFn(node.value) });
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..20dd135
--- /dev/null
@@ -0,0 +1,77 @@
+// 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';
+import { loadProcess } from '~/store/processes/processes-actions';
+
+export const Routes = {
+    ROOT: '/',
+    TOKEN: '/token',
+    PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`,
+    COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
+    PROCESSES: `/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 getProcessUrl = (uuid: string) => `/processes/${uuid}`;
+
+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 ResourceRouteParams {
+    id: string;
+}
+
+export const matchProjectRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.PROJECTS });
+
+export const matchCollectionRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.COLLECTIONS });
+
+export const matchProcessRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.PROCESSES });
+
+
+const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
+    const projectMatch = matchProjectRoute(pathname);
+    const collectionMatch = matchCollectionRoute(pathname);
+    const favoriteMatch = matchFavoritesRoute(pathname);
+    const processMatch = matchProcessRoute(pathname);
+    if (projectMatch) {
+        store.dispatch(loadProject(projectMatch.params.id));
+    } else if (collectionMatch) {
+        store.dispatch(loadCollection(collectionMatch.params.id));
+    } else if (favoriteMatch) {
+        store.dispatch(loadFavorites());
+    } else if (processMatch) {
+        store.dispatch(loadProcess(processMatch.params.id));
+    }
+};
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 c3fd43ead1d0d4c013e066f0331712710af9f38f..0c7e91deecf4aba5bc9465569c40118f9401d536 100644 (file)
@@ -4,11 +4,11 @@
 
 import { uniqBy, groupBy } from 'lodash';
 import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest";
-import { TreeNode, setNode, createTree, getNodeDescendants, getNodeValue } from '~/models/tree';
+import { TreeNode, setNode, createTree, getNodeDescendantsIds, getNodeValue } from '~/models/tree';
 import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile, CollectionFileType } from '../../models/collection-file';
 
 export const mapCollectionFilesTreeToManifest = (tree: CollectionFilesTree): KeepManifest => {
-    const values = getNodeDescendants('')(tree).map(id => getNodeValue(id)(tree));
+    const values = getNodeDescendantsIds('')(tree).map(id => getNodeValue(id)(tree));
     const files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[];
     const fileGroups = groupBy(files, file => file.path);
     return Object
diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts
new file mode 100644 (file)
index 0000000..b8a7970
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createCollectionFilesTree, CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { getTagValue } from "~/common/xml";
+import { getNodeChildren, Tree, mapTree } from '~/models/tree';
+
+export const parseFilesResponse = (document: Document) => {
+    const files = extractFilesData(document);
+    const tree = createCollectionFilesTree(files);
+    return sortFilesTree(tree);
+};
+
+export const sortFilesTree = (tree: Tree<CollectionDirectory | CollectionFile>) => {
+    return mapTree<CollectionDirectory | CollectionFile>(node => {
+        const children = getNodeChildren(node.id)(tree);
+
+        children.sort((a, b) =>
+            a.value.type !== b.value.type
+                ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
+                : a.value.name.localeCompare(b.value.name)
+        );
+        return { ...node, children: children.map(child => child.id) };
+    })(tree);
+};
+
+export const extractFilesData = (document: Document) => {
+    const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
+    return Array
+        .from(document.getElementsByTagName('D:response'))
+        .slice(1) // omit first element which is collection itself
+        .map(element => {
+            const name = getTagValue(element, 'D:displayname', '');
+            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
+            const url = getTagValue(element, 'D:href', '');
+            const nameSuffix = `/${name || ''}`;
+            const directory = url
+                .replace(collectionUrlPrefix, '')
+                .replace(nameSuffix, '');
+
+            const data = {
+                url,
+                id: `${directory}/${name}`,
+                name,
+                path: directory,
+            };
+
+            return getTagValue(element, 'D:resourcetype', '')
+                ? createCollectionDirectory(data)
+                : createCollectionFile({ ...data, size });
+
+        });
+};
index ad493b5a21483dc04677a52998070c5615a62e95..e26da78875b91963a6a1e51703f2a1f9ffbc17cc 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as _ from "lodash";
 import { CommonResourceService } from "~/common/api/common-resource-service";
 import { CollectionResource } from "~/models/collection";
-import axios, { AxiosInstance } from "axios";
-import { KeepService } from "../keep-service/keep-service";
+import { AxiosInstance } from "axios";
+import { CollectionFile, CollectionDirectory } from "~/models/collection-file";
 import { WebDAV } from "~/common/webdav";
 import { AuthService } from "../auth-service/auth-service";
-import { mapTree, getNodeChildren, getNode, TreeNode } from "../../models/tree";
-import { getTagValue } from "~/common/xml";
-import { FilterBuilder } from "~/common/api/filter-builder";
-import { CollectionFile, createCollectionFile, CollectionFileType, CollectionDirectory, createCollectionDirectory } from '~/models/collection-file';
-import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
-import { KeepManifestStream } from "~/models/keep-manifest";
-import { createCollectionFilesTree } from '~/models/collection-file';
+import { mapTreeValues } from "~/models/tree";
+import { parseFilesResponse } from "./collection-service-files-response";
+import { fileToArrayBuffer } from "~/common/file";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
 export class CollectionService extends CommonResourceService<CollectionResource> {
-    constructor(serverApi: AxiosInstance, private keepService: KeepService, private webdavClient: WebDAV, private authService: AuthService) {
+    constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService) {
         super(serverApi, "collections");
     }
 
     async files(uuid: string) {
-        const request = await this.webdavClient.propfind(`/c=${uuid}`);
+        const request = await this.webdavClient.propfind(`c=${uuid}`);
         if (request.responseXML != null) {
-            const files = this.extractFilesData(request.responseXML);
-            const tree = createCollectionFilesTree(files);
-            const sortedTree = mapTree(node => {
-                const children = getNodeChildren(node.id)(tree).map(id => getNode(id)(tree)) as TreeNode<CollectionDirectory | CollectionFile>[];
-                children.sort((a, b) =>
-                    a.value.type !== b.value.type
-                        ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
-                        : a.value.name.localeCompare(b.value.name)
-                );
-                return { ...node, children: children.map(child => child.id) };
-            })(tree);
-            return sortedTree;
+            const filesTree = parseFilesResponse(request.responseXML);
+            return mapTreeValues(this.extendFileURL)(filesTree);
         }
         return Promise.reject();
     }
 
-    async deleteFile(collectionUuid: string, filePath: string) {
-        return this.webdavClient.delete(`/c=${collectionUuid}${filePath}`);
-    }
-
-    extractFilesData(document: Document) {
-        const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
-        return Array
-            .from(document.getElementsByTagName('D:response'))
-            .slice(1) // omit first element which is collection itself
-            .map(element => {
-                const name = getTagValue(element, 'D:displayname', '');
-                const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
-                const pathname = getTagValue(element, 'D:href', '');
-                const nameSuffix = `/${name || ''}`;
-                const directory = pathname
-                    .replace(collectionUrlPrefix, '')
-                    .replace(nameSuffix, '');
-                const href = this.webdavClient.defaults.baseURL + pathname + '?api_token=' + this.authService.getApiToken();
-
-                const data = {
-                    url: href,
-                    id: `${directory}/${name}`,
-                    name,
-                    path: directory,
-                };
-
-                return getTagValue(element, 'D:resourcetype', '')
-                    ? createCollectionDirectory(data)
-                    : createCollectionFile({ ...data, size });
-
-            });
+    async deleteFiles(collectionUuid: string, filePaths: string[]) {
+        for (const path of filePaths) {
+            await this.webdavClient.delete(`c=${collectionUuid}${path}`);
+        }
     }
 
-    private readFile(file: File): Promise<ArrayBuffer> {
-        return new Promise<ArrayBuffer>(resolve => {
-            const reader = new FileReader();
-            reader.onload = () => {
-                resolve(reader.result as ArrayBuffer);
-            };
-
-            reader.readAsArrayBuffer(file);
-        });
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress) {
+        // files have to be uploaded sequentially
+        for (let idx = 0; idx < files.length; idx++) {
+            await this.uploadFile(collectionUuid, files[idx], idx, onProgress);
+        }
     }
 
-    private uploadFile(keepServiceHost: string, file: File, fileId: number, onProgress?: UploadProgress): Promise<CollectionFile> {
-        return this.readFile(file).then(content => {
-            return axios.post<string>(keepServiceHost, content, {
-                headers: {
-                    'Content-Type': 'text/octet-stream'
-                },
-                onUploadProgress: (e: ProgressEvent) => {
-                    if (onProgress) {
-                        onProgress(fileId, e.loaded, e.total, Date.now());
-                    }
-                    console.log(`${e.loaded} / ${e.total}`);
-                }
-            }).then(data => createCollectionFile({
-                id: data.data,
-                name: file.name,
-                size: file.size
-            }));
-        });
+    moveFile(collectionUuid: string, oldPath: string, newPath: string) {
+        return this.webdavClient.move(
+            `c=${collectionUuid}${oldPath}`,
+            `c=${collectionUuid}${encodeURI(newPath)}`
+        );
     }
 
-    private async updateManifest(collectionUuid: string, files: CollectionFile[]): Promise<CollectionResource> {
-        const collection = await this.get(collectionUuid);
-        const manifest: KeepManifestStream[] = parseKeepManifestText(collection.manifestText);
-
-        files.forEach(f => {
-            let kms = manifest.find(stream => stream.name === f.path);
-            if (!kms) {
-                kms = {
-                    files: [],
-                    locators: [],
-                    name: f.path
-                };
-                manifest.push(kms);
+    private extendFileURL = (file: CollectionDirectory | CollectionFile) => ({
+        ...file,
+        url: this.webdavClient.defaults.baseURL + file.url + '?api_token=' + this.authService.getApiToken()
+    })
+
+    private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
+        const fileURL = `c=${collectionUuid}/${file.name}`;
+        const fileContent = await fileToArrayBuffer(file);
+        const requestConfig = {
+            headers: {
+                'Content-Type': 'text/octet-stream'
+            },
+            onUploadProgress: (e: ProgressEvent) => {
+                onProgress(fileId, e.loaded, e.total, Date.now());
             }
-            kms.locators.push(f.id);
-            const len = kms.files.length;
-            const nextPos = len > 0
-                ? parseInt(kms.files[len - 1].position, 10) + kms.files[len - 1].size
-                : 0;
-            kms.files.push({
-                name: f.name,
-                position: nextPos.toString(),
-                size: f.size
-            });
-        });
-
-        console.log(manifest);
-
-        const manifestText = stringifyKeepManifest(manifest);
-        const data = { ...collection, manifestText };
-        return this.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
-    }
-
-    uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress): Promise<CollectionResource | never> {
-        const filters = new FilterBuilder()
-            .addEqual("service_type", "proxy");
-
-        return this.keepService.list({ filters: filters.getFilters() }).then(data => {
-            if (data.items && data.items.length > 0) {
-                const serviceHost =
-                    (data.items[0].serviceSslFlag ? "https://" : "http://") +
-                    data.items[0].serviceHost +
-                    ":" + data.items[0].servicePort;
-
-                console.log("serviceHost", serviceHost);
+        };
+        return this.webdavClient.put(fileURL, fileContent, requestConfig);
 
-                const files$ = files.map((f, idx) => this.uploadFile(serviceHost, f, idx, onProgress));
-                return Promise.all(files$).then(values => {
-                    return this.updateManifest(collectionUuid, values);
-                });
-            } else {
-                return Promise.reject("Missing keep service host");
-            }
-        });
     }
 
     trash(uuid: string): Promise<CollectionResource> {
@@ -179,5 +84,5 @@ export class CollectionService extends CommonResourceService<CollectionResource>
             })
             .then(CommonResourceService.mapResponseKeys);
     }
-
+    
 }
diff --git a/src/services/container-request-service/container-request-service.ts b/src/services/container-request-service/container-request-service.ts
new file mode 100644 (file)
index 0000000..8cf8e74
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "~/common/api/common-resource-service";
+import { AxiosInstance } from "axios";
+import { ContainerRequestResource } from '../../models/container-request';
+
+export class ContainerRequestService extends CommonResourceService<ContainerRequestResource> {
+    constructor(serverApi: AxiosInstance) {
+        super(serverApi, "container_requests");
+    }
+}
diff --git a/src/services/container-service/container-service.ts b/src/services/container-service/container-service.ts
new file mode 100644 (file)
index 0000000..698c7f5
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "~/common/api/common-resource-service";
+import { AxiosInstance } from "axios";
+import { ContainerResource } from '../../models/container';
+
+export class ContainerService extends CommonResourceService<ContainerResource> {
+    constructor(serverApi: AxiosInstance) {
+        super(serverApi, "containers");
+    }
+}
index f63194d3d0ad634d54bdddae90f845b608bea6d9..32e7bd18b2ff9bd54ee1305af28e132e5da4cfe9 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import Axios, { AxiosInstance } from "axios";
+import Axios from "axios";
 import { AuthService } from "./auth-service/auth-service";
 import { GroupsService } from "./groups-service/groups-service";
 import { ProjectService } from "./project-service/project-service";
@@ -12,40 +12,66 @@ import { CollectionService } from "./collection-service/collection-service";
 import { TagService } from "./tag-service/tag-service";
 import { CollectionFilesService } from "./collection-files-service/collection-files-service";
 import { KeepService } from "./keep-service/keep-service";
-import { WebDAV } from "~/common/webdav";
-import { Config } from "~/common/config";
+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";
+import { ContainerRequestService } from './container-request-service/container-request-service';
+import { ContainerService } from './container-service/container-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
 export const createServices = (config: Config) => {
     const apiClient = Axios.create();
-    apiClient.defaults.baseURL = `${config.apiHost}/arvados/v1`;
+    apiClient.defaults.baseURL = config.baseUrl;
 
     const webdavClient = new WebDAV();
-    webdavClient.defaults.baseURL = config.keepWebHost;
+    webdavClient.defaults.baseURL = config.keepWebServiceUrl;
 
-    const authService = new AuthService(apiClient, config.apiHost);
-    const keepService = new KeepService(apiClient);
     const groupsService = new GroupsService(apiClient);
-    const projectService = new ProjectService(apiClient);
+    const keepService = new KeepService(apiClient);
     const linkService = new LinkService(apiClient);
+    const projectService = new ProjectService(apiClient);
+    const userService = new UserService(apiClient);
+    const containerRequestService = new ContainerRequestService(apiClient);
+    const containerService = new ContainerService(apiClient);
+    
+    const ancestorsService = new AncestorService(groupsService, userService);
+    const authService = new AuthService(apiClient, config.rootUrl);
+    const collectionService = new CollectionService(apiClient, webdavClient, authService);
+    const collectionFilesService = new CollectionFilesService(collectionService);
     const favoriteService = new FavoriteService(linkService, groupsService);
-    const collectionService = new CollectionService(apiClient, keepService, webdavClient, authService);
     const tagService = new TagService(linkService);
-    const collectionFilesService = new CollectionFilesService(collectionService);
 
     return {
+        ancestorsService,
         apiClient,
-        webdavClient,
         authService,
-        keepService,
+        collectionFilesService,
+        collectionService,
+        containerRequestService,
+        containerService,
+        favoriteService,
         groupsService,
-        projectService,
+        keepService,
         linkService,
-        favoriteService,
-        collectionService,
+        projectService,
         tagService,
-        collectionFilesService
+        userService,
+        webdavClient,
     };
 };
 
+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) {
index 217a27cc49cfccd5d465a25480670ac327029e3e..4ac48a0be2afa021ab220f553847eccde259871d 100644 (file)
@@ -17,15 +17,16 @@ import 'jest-localstorage-mock';
 import { createServices } from "~/services/services";
 import { configureStore, RootStore } from "../store";
 import createBrowserHistory from "history/createBrowserHistory";
+import { mockConfig } from '~/common/config';
 
 describe('auth-actions', () => {
     let reducer: (state: AuthState | undefined, action: AuthAction) => any;
     let store: RootStore;
 
     beforeEach(() => {
-        store = configureStore(createBrowserHistory(), createServices({ apiHost: "/arvados/v1", keepWebHost: "" }));
+        store = configureStore(createBrowserHistory(), createServices(mockConfig({})));
         localStorage.clear();
-        reducer = authReducer(createServices({ apiHost: "/arvados/v1", keepWebHost: "" }));
+        reducer = authReducer(createServices(mockConfig({})));
     });
 
     it('should initialise state with user and api token from local storage', () => {
@@ -72,3 +73,5 @@ describe('auth-actions', () => {
     });
     */
 });
+
+
index 8eeb7c3c636152c9087b868cd01ca71b1678c9df..2b1920a61db9fc47e32fb543341a2010ccd59d63 100644 (file)
@@ -7,13 +7,14 @@ import { AuthAction, authActions } from "./auth-action";
 
 import 'jest-localstorage-mock';
 import { createServices } from "~/services/services";
+import { mockConfig } from '~/common/config';
 
 describe('auth-reducer', () => {
     let reducer: (state: AuthState | undefined, action: AuthAction) => any;
 
     beforeAll(() => {
         localStorage.clear();
-        reducer = authReducer(createServices({ apiHost: "/arvados/v1", keepWebHost: "" }));
+        reducer = authReducer(createServices(mockConfig({})));
     });
 
     it('should correctly initialise state', () => {
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..97b6d49c7d25d154bd51471c94ef992fcb2d27f8 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,22 @@ 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(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: collection }));
+        dispatch(resourcesActions.SET_RESOURCES([collection]));
+        dispatch<any>(loadCollectionFiles(collection.uuid));
+        dispatch<any>(loadCollectionTags(collection.uuid));
+        return collection;
     };
 
 export const loadCollectionTags = (uuid: string) =>
@@ -50,7 +51,6 @@ export const loadCollectionTags = (uuid: string) =>
             });
     };
 
-
 export const createCollectionTag = (data: TagProperty) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(collectionPanelActions.CREATE_COLLECTION_TAG({ data }));
index cedfbebef5aded305b3237e125ee80857c96361b..413fedfc1c08eccd17fe24f4c22aecd03d5a000e 100644 (file)
@@ -2,15 +2,19 @@
 //
 // 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";
 import { RootState } from "../../store";
 import { snackbarActions } from "../../snackbar/snackbar-actions";
-import { dialogActions } from "../../dialog/dialog-actions";
-import { getNodeValue, getNodeDescendants } from "~/models/tree";
-import { CollectionPanelDirectory, CollectionPanelFile } from "./collection-panel-files-state";
+import { dialogActions } from '../../dialog/dialog-actions';
+import { getNodeValue } from "~/models/tree";
+import { filterCollectionFilesBySelection } from './collection-panel-files-state';
+import { startSubmit, initialize, stopSubmit, reset } from 'redux-form';
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
+import { getDialog } from "~/store/dialog/dialog-reducer";
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
 
 export const collectionPanelFilesAction = unionize({
     SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
@@ -18,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>;
 
@@ -30,30 +34,23 @@ export const loadCollectionFiles = (uuid: string) =>
 
 export const removeCollectionFiles = (filePaths: string[]) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { item } = getState().collectionPanel;
-        if (item) {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-            const promises = filePaths.map(filePath => services.collectionService.deleteFile(item.uuid, filePath));
-            await Promise.all(promises);
-            dispatch<any>(loadCollectionFiles(item.uuid));
+            await services.collectionService.deleteFiles(currentCollection.uuid, filePaths);
+            dispatch<any>(loadCollectionFiles(currentCollection.uuid));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
         }
     };
 
 export const removeCollectionsSelectedFiles = () =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const tree = getState().collectionPanelFiles;
-        const allFiles = getNodeDescendants('')(tree)
-            .map(id => getNodeValue(id)(tree))
-            .filter(file => file !== undefined) as Array<CollectionPanelDirectory | CollectionPanelFile>;
-
-        const selectedDirectories = allFiles.filter(file => file.selected && file.type === CollectionFileType.DIRECTORY);
-        const selectedFiles = allFiles.filter(file => file.selected && !selectedDirectories.some(dir => dir.id === file.path));
-        const paths = [...selectedDirectories, ...selectedFiles].map(file => file.id);
+        const paths = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).map(file => file.id);
         dispatch<any>(removeCollectionFiles(paths));
     };
 
 export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
+
 export const openFileRemoveDialog = (filePath: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const file = getNodeValue(filePath)(getState().collectionPanelFiles);
@@ -78,6 +75,7 @@ export const openFileRemoveDialog = (filePath: string) =>
     };
 
 export const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
+
 export const openMultipleFilesRemoveDialog = () =>
     dialogActions.OPEN_DIALOG({
         id: MULTIPLE_FILES_REMOVE_DIALOG,
@@ -87,3 +85,34 @@ export const openMultipleFilesRemoveDialog = () =>
             confirmButtonLabel: 'Remove'
         }
     });
+
+export const RENAME_FILE_DIALOG = 'renameFileDialog';
+export interface RenameFileDialogData {
+    name: string;
+    id: string;
+}
+
+export const openRenameFileDialog = (data: RenameFileDialogData) =>
+    (dispatch: Dispatch) => {
+        dispatch(reset(RENAME_FILE_DIALOG));
+        dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data }));
+    };
+
+export const renameFile = (newName: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const dialog = getDialog<RenameFileDialogData>(getState().dialog, RENAME_FILE_DIALOG);
+        const currentCollection = getState().collectionPanel.item;
+        if (dialog && currentCollection) {
+            dispatch(startSubmit(RENAME_FILE_DIALOG));
+            const oldPath = dialog.data.id;
+            const newPath = dialog.data.id.replace(dialog.data.name, newName);
+            try {
+                await services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath);
+                dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+                dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
+            } catch (e) {
+                dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Could not rename the file' }));
+            }
+        }
+    };
index 08b60308c42bb9d4f3dfdeb72eae23f4b45946de..57961538708c900b56631e47e33639a90d66a559 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from "./collection-panel-files-state";
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from './collection-panel-files-state';
 import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
-import { createTree, mapTreeValues, getNode, setNode, getNodeAncestors, getNodeDescendants, setNodeValueWith, mapTree } from "~/models/tree";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
 
 export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
@@ -44,7 +44,7 @@ const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
 const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
     const node = getNode(id)(tree);
     if (node && node.value.type === CollectionFileType.DIRECTORY) {
-        return getNodeDescendants(id)(tree)
+        return getNodeDescendantsIds(id)(tree)
             .reduce((newTree, id) =>
                 setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
     }
@@ -52,7 +52,7 @@ const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
 };
 
 const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
-    const ancestors = getNodeAncestors(id)(tree).reverse();
+    const ancestors = getNodeAncestorsIds(id)(tree).reverse();
     return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
 };
 
index 35b81d2e121e134b1b10f8aad6223a0267da9765..9d5b06cea6b9c94f74e5fadbebd022b5b6366178 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Tree, TreeNode, mapTreeValues, getNodeValue } from '~/models/tree';
+import { Tree, TreeNode, mapTreeValues, getNodeValue, getNodeDescendants } from '~/models/tree';
 import { CollectionFile, CollectionDirectory, CollectionFileType } from '~/models/collection-file';
 
 export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
@@ -34,4 +34,12 @@ export const mergeCollectionPanelFilesStates = (oldState: CollectionPanelFilesSt
                 : { ...value, selected: oldValue.selected }
             : value;
     })(newState);
-}; 
+};
+
+export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean) => {
+    const allFiles = getNodeDescendants('')(tree).map(node => node.value);
+
+    const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
+    const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
+    return [...selectedDirectories, ...selectedFiles];
+};
diff --git a/src/store/collections/collection-copy-actions.ts b/src/store/collections/collection-copy-actions.ts
new file mode 100644 (file)
index 0000000..15ea855
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { initialize, startSubmit, stopSubmit } from 'redux-form';
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from '~/services/services';
+import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service';
+
+export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
+
+export interface CollectionCopyFormDialogData {
+    name: string;
+    ownerUuid: string;
+    uuid: string;
+}
+
+export const openCollectionCopyDialog = (resource: { name: string, uuid: string }) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        const initialData: CollectionCopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: '', uuid: resource.uuid };
+        dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
+    };
+
+export const copyCollection = (resource: CollectionCopyFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_COPY_FORM_NAME));
+        try {
+            const collection = await services.collectionService.get(resource.uuid);
+            const uuidKey = 'uuid';
+            delete collection[uuidKey];
+            await services.collectionService.create({ ...collection, ownerUuid: resource.ownerUuid, name: resource.name });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
+            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 }));
+                throw new Error('Could not copy the collection.');
+            }
+            return ;
+        }
+    };
\ No newline at end of file
diff --git a/src/store/collections/collection-create-actions.ts b/src/store/collections/collection-create-actions.ts
new file mode 100644 (file)
index 0000000..5a1246a
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { reset, startSubmit, stopSubmit, initialize } from 'redux-form';
+import { RootState } from '~/store/store';
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { ServiceRepository } from '~/services/services';
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
+import { uploadCollectionFiles } from './collection-upload-actions';
+import { fileUploaderActions } from '~/store/file-uploader/file-uploader-actions';
+
+export interface CollectionCreateFormDialogData {
+    ownerUuid: string;
+    name: string;
+    description: string;
+}
+
+export const COLLECTION_CREATE_FORM_NAME = "collectionCreateFormName";
+
+export const openCollectionCreateDialog = (ownerUuid: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(COLLECTION_CREATE_FORM_NAME, { ownerUuid }));
+        dispatch(fileUploaderActions.CLEAR_UPLOAD());
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_CREATE_FORM_NAME, data: { ownerUuid } }));
+    };
+
+export const createCollection = (data: CollectionCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_CREATE_FORM_NAME));
+        try {
+            const newCollection = await services.collectionService.create(data);
+            await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
+            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 ;
+        }
+    };
diff --git a/src/store/collections/collection-move-actions.ts b/src/store/collections/collection-move-actions.ts
new file mode 100644 (file)
index 0000000..dcd7b1a
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "~/store/dialog/dialog-actions";
+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 { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+
+export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
+
+export const openMoveCollectionDialog = (resource: { name: string, uuid: string }) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
+    };
+
+export const moveCollection = (resource: MoveToFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
+        try {
+            const collection = await services.collectionService.get(resource.uuid);
+            await services.collectionService.update(resource.uuid, { ...collection, ownerUuid: resource.ownerUuid });
+            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) {
+                dispatch(stopSubmit(COLLECTION_MOVE_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' }));
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000 }));
+            }
+            return ;
+        }
+    };
diff --git a/src/store/collections/collection-partial-copy-actions.ts b/src/store/collections/collection-partial-copy-actions.ts
new file mode 100644 (file)
index 0000000..a063aba
--- /dev/null
@@ -0,0 +1,71 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { initialize, startSubmit, stopSubmit } from 'redux-form';
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { ServiceRepository } from '~/services/services';
+import { filterCollectionFilesBySelection } from '../collection-panel/collection-panel-files/collection-panel-files-state';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service';
+
+export const COLLECTION_PARTIAL_COPY_FORM_NAME = 'COLLECTION_PARTIAL_COPY_DIALOG';
+
+export interface CollectionPartialCopyFormData {
+    name: string;
+    description: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialCopyDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
+            const initialData = {
+                name: currentCollection.name,
+                description: currentCollection.description,
+                projectUuid: ''
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_COPY_FORM_NAME, initialData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME, data: {} }));
+        }
+    };
+
+export const copyCollectionPartial = ({ name, description, projectUuid }: CollectionPartialCopyFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
+        const state = getState();
+        const currentCollection = state.collectionPanel.item;
+        if (currentCollection) {
+            try {
+                const collection = await services.collectionService.get(currentCollection.uuid);
+                const collectionCopy = {
+                    ...collection,
+                    name,
+                    description,
+                    ownerUuid: projectUuid,
+                    uuid: undefined
+                };
+                const newCollection = await services.collectionService.create(collectionCopy);
+                const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
+                await services.collectionService.deleteFiles(newCollection.uuid, paths);
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'New collection created.', hideDuration: 2000 }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+                    dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME, { name: 'Collection with this name already exists.' }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000 }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000 }));
+                }
+            }
+        }
+    };
\ No newline at end of file
diff --git a/src/store/collections/collection-update-actions.ts b/src/store/collections/collection-update-actions.ts
new file mode 100644 (file)
index 0000000..75e03d5
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { 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";
+import { ContextMenuResource } from '~/store/context-menu/context-menu-reducer';
+import { PROJECT_PANEL_ID } from "~/views/project-panel/project-panel";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
+import { ServiceRepository } from "~/services/services";
+import { CollectionResource } from '~/models/collection';
+
+export interface CollectionUpdateFormDialogData {
+    uuid: string;
+    name: string;
+    description: string;
+}
+
+export const COLLECTION_UPDATE_FORM_NAME = 'collectionUpdateFormName';
+
+export const openCollectionUpdateDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(COLLECTION_UPDATE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME, data: {} }));
+    };
+
+export const updateCollection = (collection: Partial<CollectionResource>) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = collection.uuid || '';
+        dispatch(startSubmit(COLLECTION_UPDATE_FORM_NAME));
+        try {
+            const updatedCollection = await services.collectionService.update(uuid, collection);
+            dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+            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
diff --git a/src/store/collections/collection-upload-actions.ts b/src/store/collections/collection-upload-actions.ts
new file mode 100644 (file)
index 0000000..4a5aff3
--- /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 { ServiceRepository } from '~/services/services';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { loadCollectionFiles } from '../collection-panel/collection-panel-files/collection-panel-files-actions';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { fileUploaderActions } from '~/store/file-uploader/file-uploader-actions';
+import { reset, startSubmit } from 'redux-form';
+
+export const uploadCollectionFiles = (collectionUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(fileUploaderActions.START_UPLOAD());
+        const files = getState().fileUploader.map(file => file.file);
+        await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch));
+        dispatch(fileUploaderActions.CLEAR_UPLOAD());
+    };
+
+export const COLLECTION_UPLOAD_FILES_DIALOG = 'uploadCollectionFilesDialog';
+
+export const openUploadCollectionFilesDialog = () => (dispatch: Dispatch) => {
+    dispatch(reset(COLLECTION_UPLOAD_FILES_DIALOG));
+    dispatch(fileUploaderActions.CLEAR_UPLOAD());
+    dispatch<any>(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPLOAD_FILES_DIALOG, data: {} }));
+};
+
+export const submitCollectionFiles = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
+            dispatch(startSubmit(COLLECTION_UPLOAD_FILES_DIALOG));
+            await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));
+            dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+            dispatch(closeUploadCollectionFilesDialog());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Data has been uploaded.', hideDuration: 2000 }));
+        }
+    };
+
+export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPLOAD_FILES_DIALOG });
+
+const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {
+    dispatch(fileUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
+};
\ No newline at end of file
diff --git a/src/store/collections/collections-reducer.ts b/src/store/collections/collections-reducer.ts
deleted file mode 100644 (file)
index b2ee455..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { combineReducers } from 'redux';
-import { collectionCreatorReducer, CollectionCreatorState } from "./creator/collection-creator-reducer";
-import { collectionUpdaterReducer, CollectionUpdaterState } from "./updater/collection-updater-reducer";
-import { collectionUploaderReducer, CollectionUploaderState } from "./uploader/collection-uploader-reducer";
-
-export type CollectionsState = {
-    creator: CollectionCreatorState;
-    updater: CollectionUpdaterState;
-    uploader: CollectionUploaderState
-};
-
-export const collectionsReducer = combineReducers({
-    creator: collectionCreatorReducer,
-    updater: collectionUpdaterReducer,
-    uploader: collectionUploaderReducer
-});
diff --git a/src/store/collections/creator/collection-creator-action.ts b/src/store/collections/creator/collection-creator-action.ts
deleted file mode 100644 (file)
index 323ba8d..0000000
+++ /dev/null
@@ -1,46 +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 { Dispatch } from "redux";
-
-import { RootState } from "../../store";
-import { CollectionResource } from '~/models/collection';
-import { ServiceRepository } from "~/services/services";
-import { collectionUploaderActions } from "../uploader/collection-uploader-actions";
-import { reset } from "redux-form";
-
-export const collectionCreateActions = unionize({
-    OPEN_COLLECTION_CREATOR: ofType<{ ownerUuid: string }>(),
-    CLOSE_COLLECTION_CREATOR: ofType<{}>(),
-    CREATE_COLLECTION: ofType<{}>(),
-    CREATE_COLLECTION_SUCCESS: ofType<{}>(),
-}, {
-    tag: 'type',
-    value: 'payload'
-});
-
-export const createCollection = (collection: Partial<CollectionResource>, files: File[]) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { ownerUuid } = getState().collections.creator;
-        const collectiontData = { ownerUuid, ...collection };
-        dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
-        return services.collectionService
-            .create(collectiontData)
-            .then(collection => {
-                dispatch(collectionUploaderActions.START_UPLOAD());
-                services.collectionService.uploadFiles(collection.uuid, files,
-                    (fileId, loaded, total, currentTime) => {
-                        dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
-                    })
-                .then(collection => {
-                    dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
-                    dispatch(reset('collectionCreateDialog'));
-                    dispatch(collectionUploaderActions.CLEAR_UPLOAD());
-                });
-                return collection;
-            });
-    };
-
-export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
diff --git a/src/store/collections/creator/collection-creator-reducer.test.ts b/src/store/collections/creator/collection-creator-reducer.test.ts
deleted file mode 100644 (file)
index 5aa9dcf..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { collectionCreatorReducer } from "./collection-creator-reducer";
-import { collectionCreateActions } from "./collection-creator-action";
-
-describe('collection-reducer', () => {
-
-    it('should open collection creator dialog', () => {
-        const initialState = { opened: false, ownerUuid: "" };
-        const collection = { opened: true, ownerUuid: "" };
-
-        const state = collectionCreatorReducer(initialState, collectionCreateActions.OPEN_COLLECTION_CREATOR(initialState));
-        expect(state).toEqual(collection);
-    });
-
-    it('should close collection creator dialog', () => {
-        const initialState = { opened: true, ownerUuid: "" };
-        const collection = { opened: false, ownerUuid: "" };
-
-        const state = collectionCreatorReducer(initialState, collectionCreateActions.CLOSE_COLLECTION_CREATOR());
-        expect(state).toEqual(collection);
-    });
-
-    it('should reset collection creator dialog props', () => {
-        const initialState = { opened: true, ownerUuid: "test" };
-        const collection = { opened: false, ownerUuid: "" };
-
-        const state = collectionCreatorReducer(initialState, collectionCreateActions.CREATE_COLLECTION_SUCCESS());
-        expect(state).toEqual(collection);
-    });
-});
diff --git a/src/store/collections/creator/collection-creator-reducer.ts b/src/store/collections/creator/collection-creator-reducer.ts
deleted file mode 100644 (file)
index 72c999d..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { collectionCreateActions, CollectionCreateAction } from './collection-creator-action';
-
-export interface CollectionCreatorState {
-    opened: boolean;
-    ownerUuid: string;
-}
-
-const updateCreator = (state: CollectionCreatorState, creator?: Partial<CollectionCreatorState>) => ({
-    ...state,
-    ...creator
-});
-
-const initialState: CollectionCreatorState = {
-    opened: false,
-    ownerUuid: ''
-};
-
-export const collectionCreatorReducer = (state: CollectionCreatorState = initialState, action: CollectionCreateAction) => {
-    return collectionCreateActions.match(action, {
-        OPEN_COLLECTION_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true }),
-        CLOSE_COLLECTION_CREATOR: () => updateCreator(state, { opened: false }),
-        CREATE_COLLECTION: () => updateCreator(state),
-        CREATE_COLLECTION_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
-        default: () => state
-    });
-};
diff --git a/src/store/collections/updater/collection-updater-action.ts b/src/store/collections/updater/collection-updater-action.ts
deleted file mode 100644 (file)
index 2f520d4..0000000
+++ /dev/null
@@ -1,49 +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 { Dispatch } from "redux";
-
-import { RootState } from "../../store";
-import { ServiceRepository } from "~/services/services";
-import { CollectionResource } from '~/models/collection';
-import { initialize } from 'redux-form';
-import { collectionPanelActions } from "../../collection-panel/collection-panel-action";
-import { ContextMenuResource } from "../../context-menu/context-menu-reducer";
-import { updateDetails } from "~/store/details-panel/details-panel-action";
-
-export const collectionUpdaterActions = unionize({
-    OPEN_COLLECTION_UPDATER: ofType<{ uuid: string }>(),
-    CLOSE_COLLECTION_UPDATER: ofType<{}>(),
-    UPDATE_COLLECTION_SUCCESS: ofType<{}>(),
-}, {
-    tag: 'type',
-    value: 'payload'
-});
-
-
-export const COLLECTION_FORM_NAME = 'collectionEditDialog';
-
-export const openUpdater = (item: ContextMenuResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        if (item) {
-            dispatch(collectionUpdaterActions.OPEN_COLLECTION_UPDATER({ uuid: item.uuid }));
-            dispatch(initialize(COLLECTION_FORM_NAME, { name: item.name, description: item.description }));
-        }
-    };
-
-export const updateCollection = (collection: Partial<CollectionResource>) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { uuid } = getState().collections.updater;
-        return services.collectionService
-            .update(uuid, collection)
-            .then(collection => {
-                    dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: collection as CollectionResource }));
-                    dispatch(collectionUpdaterActions.UPDATE_COLLECTION_SUCCESS());
-                    dispatch<any>(updateDetails(collection));
-                }
-            );
-    };
-
-export type CollectionUpdaterAction = UnionOf<typeof collectionUpdaterActions>;
diff --git a/src/store/collections/updater/collection-updater-reducer.ts b/src/store/collections/updater/collection-updater-reducer.ts
deleted file mode 100644 (file)
index 97d010f..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { collectionUpdaterActions, CollectionUpdaterAction } from './collection-updater-action';
-
-export interface CollectionUpdaterState {
-    opened: boolean;
-    uuid: string;
-}
-
-const updateCollection = (state: CollectionUpdaterState, updater?: Partial<CollectionUpdaterState>) => ({
-    ...state,
-    ...updater
-});
-
-const initialState: CollectionUpdaterState = {
-    opened: false,
-    uuid: ''
-};
-
-export const collectionUpdaterReducer = (state: CollectionUpdaterState = initialState, action: CollectionUpdaterAction) => {
-    return collectionUpdaterActions.match(action, {
-        OPEN_COLLECTION_UPDATER: ({ uuid }) => updateCollection(state, { uuid, opened: true }),
-        CLOSE_COLLECTION_UPDATER: () => updateCollection(state, { opened: false, uuid: "" }),
-        UPDATE_COLLECTION_SUCCESS: () => updateCollection(state, { opened: false, uuid: "" }),
-        default: () => state
-    });
-};
index 8e5eb1e795791260474d92e0fffe6e596560081d..a1ed6c5536bc4b71bb2b6b8e26ed5fc4f16dfcf7 100644 (file)
 //
 // 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 type ContextMenuResource = {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+    description?: string;
+    kind: ContextMenuKind;
+    isTrashed?: boolean;
+}
+
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
+    (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 res = getResource<UserResource>(projectUuid)(getState().resources);
+        if (res) {
+            dispatch<any>(openContextMenu(event, {
+                name: '',
+                uuid: res.uuid,
+                ownerUuid: res.uuid,
+                kind: ContextMenuKind.ROOT_PROJECT,
+                isTrashed: false
+            }));
+        }
+    };
+
+export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const res = getResource<ProjectResource>(projectUuid)(getState().resources);
+        if (res) {
+            dispatch<any>(openContextMenu(event, {
+                name: res.name,
+                uuid: res.uuid,
+                kind: ContextMenuKind.PROJECT,
+                ownerUuid: res.ownerUuid,
+                isTrashed: res.isTrashed
+            }));
+        }
+    };
+
+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 openProcessContextMenu = (event: React.MouseEvent<HTMLElement>) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const resource = {
+            uuid: '',
+            name: '',
+            description: '',
+            kind: ContextMenuKind.PROCESS
+        };
+        dispatch<any>(openContextMenu(event, resource));
+    };
+
+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 0bc44ba85bf06195bb34ef88500ec807c4b99e54..d26d768a0ecd089447d587a08190ef4ebe24a45f 100644 (file)
@@ -29,8 +29,8 @@ describe('data-explorer-reducer', () => {
             filters: [],
             render: jest.fn(),
             selected: true,
-            configurable: true,
-            sortDirection: SortDirection.ASC
+            sortDirection: SortDirection.ASC,
+            configurable: true
         }, {
             name: "Column 2",
             filters: [],
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 34d38fdf4ea6e2d39dc28814a2f2a1dcee5e9733..48f8ee8a1e6caac149fb07b8c94a10a46a3f2377 100644 (file)
@@ -4,11 +4,11 @@
 
 import { DialogAction, dialogActions } from "./dialog-actions";
 
-export type DialogState = Record<string, Dialog>;
+export type DialogState = Record<string, Dialog<any>>;
 
-export interface Dialog {
+export interface Dialog <T> {
     open: boolean;
-    data: any;
+    data: T;
 }
 
 export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
@@ -20,3 +20,5 @@ export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
         default: () => state,
     });
 
+export const getDialog = <T>(state: DialogState, id: string) => 
+    state[id] ? state[id] as Dialog<T> : undefined;
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));
             });
     };
-
similarity index 61%
rename from src/store/collections/uploader/collection-uploader-actions.ts
rename to src/store/file-uploader/file-uploader-actions.ts
index f6b6bfa758b4c4ab5418a9f43912a4f2423b5ccf..906263fe140d0af10f75fed425c2e53d20d866a5 100644 (file)
@@ -1,28 +1,25 @@
-// Copyright (C) The Arvados Authors. All rights reserved.\r
-//\r
-// SPDX-License-Identifier: AGPL-3.0\r
-\r
-import { default as unionize, ofType, UnionOf } from "unionize";\r
-\r
-export interface UploadFile {\r
-    id: number;\r
-    file: File;\r
-    prevLoaded: number;\r
-    loaded: number;\r
-    total: number;\r
-    startTime: number;\r
-    prevTime: number;\r
-    currentTime: number;\r
-}\r
-\r
-export const collectionUploaderActions = unionize({\r
-    SET_UPLOAD_FILES: ofType<File[]>(),\r
-    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
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+
+export interface UploadFile {
+    id: number;
+    file: File;
+    prevLoaded: number;
+    loaded: number;
+    total: number;
+    startTime: number;
+    prevTime: number;
+    currentTime: number;
+}
+
+export const fileUploaderActions = unionize({
+    CLEAR_UPLOAD: ofType(),
+    SET_UPLOAD_FILES: ofType<File[]>(),
+    SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(),
+    START_UPLOAD: ofType(),
+});
+
+export type FileUploaderAction = UnionOf<typeof fileUploaderActions>;
similarity index 69%
rename from src/store/collections/uploader/collection-uploader-reducer.ts
rename to src/store/file-uploader/file-uploader-reducer.ts
index 79a8a62339e5e3633912049ca7701aacfce2925a..625306f0c3a50afcb87e8158ddbd7719f9668df2 100644 (file)
@@ -2,14 +2,14 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { CollectionUploaderAction, collectionUploaderActions, UploadFile } from "./collection-uploader-actions";
+import { UploadFile, fileUploaderActions, FileUploaderAction } from "./file-uploader-actions";
 
-export type CollectionUploaderState = UploadFile[];
+export type UploaderState = UploadFile[];
 
-const initialState: CollectionUploaderState = [];
+const initialState: UploaderState = [];
 
-export const collectionUploaderReducer = (state: CollectionUploaderState = initialState, action: CollectionUploaderAction) => {
-    return collectionUploaderActions.match(action, {
+export const fileUploaderReducer = (state: UploaderState = initialState, action: FileUploaderAction) => {
+    return fileUploaderActions.match(action, {
         SET_UPLOAD_FILES: files => files.map((f, idx) => ({
             id: idx,
             file: f,
diff --git a/src/store/move-to-dialog/move-to-dialog.ts b/src/store/move-to-dialog/move-to-dialog.ts
new file mode 100644 (file)
index 0000000..6261a79
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface MoveToFormDialogData {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+}
\ No newline at end of file
index ad70d9d58b4c92897554e4efadb8c9019d07300c..188acf12bd30afc55b838f6d31f279822e0b6628 100644 (file)
@@ -2,99 +2,34 @@
 //
 // 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({ itemId: 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({ itemId: 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({ itemId: 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, getProcessUrl } 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));
+        } else if (kind === ResourceKind.CONTAINER_REQUEST) {
+            dispatch<any>(navigateToProcess(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({ itemId: uuid }));
-        });
-    };
+export const navigateToFavorites = push(Routes.FAVORITES);
+
+export const navigateToProject = compose(push, getProjectUrl);
 
-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 navigateToCollection = compose(push, getCollectionUrl);
 
-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 navigateToProcess = compose(push, getProcessUrl);
diff --git a/src/store/processes/process.ts b/src/store/processes/process.ts
new file mode 100644 (file)
index 0000000..46d8a25
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContainerRequestResource } from '../../models/container-request';
+import { ContainerResource } from '../../models/container';
+import { ResourcesState, getResource } from '~/store/resources/resources';
+import { filterResources } from '../resources/resources';
+import { ResourceKind, Resource } from '~/models/resource';
+
+export interface Process {
+    containerRequest: ContainerRequestResource;
+    container?: ContainerResource;
+}
+
+export const getProcess = (uuid: string) => (resources: ResourcesState): Process | undefined => {
+    const containerRequest = getResource<ContainerRequestResource>(uuid)(resources);
+    if (containerRequest) {
+        if (containerRequest.containerUuid) {
+            const container = getResource<ContainerResource>(containerRequest.containerUuid)(resources);
+            if (container) {
+                return { containerRequest, container };
+            }
+        }
+        return { containerRequest };
+    }
+    return;
+};
+
+export const getSubprocesses = (uuid: string) => (resources: ResourcesState) => {
+    const containerRequests = filterResources(isSubprocess(uuid))(resources) as ContainerRequestResource[];
+    return containerRequests.reduce((subprocesses, { uuid }) => {
+        const process = getProcess(uuid)(resources);
+        return process
+            ? [...subprocesses, process]
+            : subprocesses;
+    }, []);
+};
+
+export const getProcessStatus = (process: Process) =>
+    process.container
+        ? process.container.state
+        : process.containerRequest.state;
+
+const isSubprocess = (uuid: string) => (resource: Resource) =>
+    resource.kind === ResourceKind.CONTAINER_REQUEST
+    && (resource as ContainerRequestResource).requestingContainerUuid === uuid;
diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts
new file mode 100644 (file)
index 0000000..d667517
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from '~/store/store';
+import { ServiceRepository } from '~/services/services';
+import { updateResources } from '~/store/resources/resources-actions';
+import { FilterBuilder } from '~/common/api/filter-builder';
+import { ContainerRequestResource } from '../../models/container-request';
+
+export const loadProcess = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const containerRequest = await services.containerRequestService.get(uuid);
+        dispatch<any>(updateResources([containerRequest]));
+        if (containerRequest.containerUuid) {
+            const container = await services.containerService.get(containerRequest.containerUuid);
+            dispatch<any>(updateResources([container]));
+        }
+    };
+
+export const loadSubprocesses = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const containerRequests = await dispatch<any>(loadContainerRequests(
+            new FilterBuilder().addEqual('requestingContainerUuid', uuid).getFilters()
+        )) as ContainerRequestResource[];
+
+        const containerUuids: string[] = containerRequests.reduce((uuids, { containerUuid }) =>
+            containerUuid
+                ? [...uuids, containerUuid]
+                : uuids, []);
+
+        if (containerUuids.length > 0) {
+            await dispatch<any>(loadContainers(
+                new FilterBuilder().addIn('uuid', containerUuids).getFilters()
+            ));
+        }
+    };
+
+export const loadContainerRequests = (filters: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { items } = await services.containerRequestService.list({ filters });
+        dispatch<any>(updateResources(items));
+        return items;
+    };
+
+export const loadContainers = (filters: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { items } = await services.containerService.list({ filters });
+        dispatch<any>(updateResources(items));
+        return items;
+    };
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-tree-picker/project-tree-picker-actions.ts b/src/store/project-tree-picker/project-tree-picker-actions.ts
new file mode 100644 (file)
index 0000000..86d9a18
--- /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 { ServiceRepository } from "~/services/services";
+import { TreePickerId, receiveTreePickerData } from "~/views-components/project-tree-picker/project-tree-picker";
+import { mockProjectResource } from "~/models/test-utils";
+import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
+
+export const resetPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.PROJECTS}));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.SHARED_WITH_ME}));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.FAVORITES}));
+
+    dispatch<any>(initPickerProjectTree());
+};
+
+export const initPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const uuid = services.authService.getUuid();
+
+    dispatch<any>(getPickerTreeProjects(uuid));
+    dispatch<any>(getSharedWithMeProjectsPickerTree(uuid));
+    dispatch<any>(getFavoritesProjectsPickerTree(uuid));
+};
+
+const getPickerTreeProjects = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.PROJECTS);
+};
+
+const getSharedWithMeProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.SHARED_WITH_ME);
+};
+
+const getFavoritesProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.FAVORITES);
+};
+
+const getProjectsPickerTree = (uuid: string, kind: string) => {
+    return receiveTreePickerData(
+        '',
+        [mockProjectResource({ uuid, name: kind })],
+        kind
+    );
+};
\ No newline at end of file
diff --git a/src/store/project/project-reducer.test.ts b/src/store/project/project-reducer.test.ts
deleted file mode 100644 (file)
index cf47010..0000000
+++ /dev/null
@@ -1,186 +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: 0
-            }, {
-                active: false,
-                open: false,
-                id: "2",
-                items: [],
-                data: mockProjectResource({ uuid: "2" }),
-                status: TreeItemStatus.INITIAL
-            }
-            ],
-            currentItemId: "",
-            creator: {
-                opened: false,
-                ownerUuid: "",
-            },
-            updater: {
-                opened: false,
-                uuid: ''
-            }
-        });
-    });
-
-    it('should remove activity on projects list', () => {
-        const initialState = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: true,
-                status: TreeItemStatus.PENDING
-            }],
-            currentItemId: "1",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: false,
-                status: TreeItemStatus.PENDING
-            }],
-            currentItemId: "",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-        };
-
-        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",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: true,
-                active: true,
-                status: TreeItemStatus.PENDING,
-            }],
-            currentItemId: "1",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-        };
-
-        const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE({ itemId: 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",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-        };
-        const project = {
-            items: [{
-                data: mockProjectResource(),
-                id: "1",
-                open: false,
-                active: false,
-                status: TreeItemStatus.PENDING,
-            }],
-            currentItemId: "1",
-            creator: { opened: false, ownerUuid: "" },
-            updater: { opened: false, uuid: '' }
-
-        };
-
-        const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN({ itemId: 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 6b473cc..0000000
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-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,
-    creator: ProjectCreator,
-    updater: ProjectUpdater
-};
-
-interface ProjectCreator {
-    opened: boolean;
-    ownerUuid: string;
-    error?: string;
-}
-
-interface ProjectUpdater {
-    opened: boolean;
-    uuid: string;
-}
-
-function rebuildTree<T>(tree: Array<TreeItem<T>>, action: (item: TreeItem<T>, visitedItems: TreeItem<T>[]) => void, visitedItems: TreeItem<T>[] = []): Array<TreeItem<T>> {
-    const newTree: Array<TreeItem<T>> = [];
-    for (const t of tree) {
-        const items = t.items
-            ? rebuildTree(t.items, action, visitedItems.concat(t))
-            : undefined;
-        const item: TreeItem<T> = { ...t, items };
-        action(item, visitedItems);
-        newTree.push(item);
-    }
-    return newTree;
-}
-
-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 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 [];
-}
-
-const updateCreator = (state: ProjectState, creator: Partial<ProjectCreator>) => ({
-    ...state,
-    creator: {
-        ...state.creator,
-        ...creator
-    }
-});
-
-const updateProject = (state: ProjectState, updater?: Partial<ProjectUpdater>) => ({
-    ...state,
-    updater: {
-        ...state.updater,
-        ...updater
-    }
-});
-
-const initialState: ProjectState = {
-    items: [],
-    currentItemId: "",
-    creator: {
-        opened: false,
-        ownerUuid: ""
-    },
-    updater: {
-        opened: false,
-        uuid: ''
-    }
-};
-
-export const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => {
-    return projectActions.match(action, {
-        OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true }),
-        CLOSE_PROJECT_CREATOR: () => updateCreator(state, { opened: false }),
-        CREATE_PROJECT: () => updateCreator(state, { error: undefined }),
-        CREATE_PROJECT_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
-        OPEN_PROJECT_UPDATER: ({ uuid }) => updateProject(state, { uuid, opened: true }),
-        CLOSE_PROJECT_UPDATER: () => updateProject(state, { opened: false, uuid: "" }),
-        UPDATE_PROJECT_SUCCESS: () => updateProject(state, { opened: false, uuid: "" }),
-        REMOVE_PROJECT: () => state,
-        PROJECTS_REQUEST: itemId => {
-            return {
-                ...state,
-                items: rebuildTree(state.items, item => {
-                    if (item.id === itemId) {
-                        item.status = TreeItemStatus.PENDING;
-                    }
-                })
-            };
-        },
-        PROJECTS_SUCCESS: ({ projects, parentItemId }) => {
-            const items = projects.map(p => ({
-               id: p.uuid,
-               open: false,
-               active: false,
-               status: TreeItemStatus.INITIAL,
-               data: p,
-               items: []
-            }));
-            return {
-                ...state,
-                items: state.items.length > 0 ?
-                    rebuildTree(state.items, item => {
-                        if (item.id === parentItemId) {
-                           item.status = TreeItemStatus.LOADED;
-                           item.items = items;
-                        }
-                    }) : items
-            };
-        },
-        TOGGLE_PROJECT_TREE_ITEM_OPEN: ({ itemId, open, recursive }) => ({
-            ...state,
-            items: rebuildTree(state.items, (item, visitedItems) => {
-                if (item.id === itemId) {
-                    if (recursive && open !== undefined) {
-                        visitedItems.forEach(item => item.open = open);
-                    }
-                    item.open = open !== undefined ? open : !item.open;
-                }
-            }),
-            currentItemId: itemId
-        }),
-        TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ({ itemId, active, recursive }) => ({
-            ...state,
-            items: rebuildTree(state.items, (item, visitedItems) => {
-                item.active = false;
-                if (item.id === itemId) {
-                    if (recursive && active !== undefined) {
-                        visitedItems.forEach(item => item.active = active);
-                    }
-
-                    item.active = active !== undefined ? active : true;
-                }
-            }),
-            currentItemId: itemId
-        }),
-        RESET_PROJECT_TREE_ACTIVITY: () => ({
-            ...state,
-            items: rebuildTree(state.items, item => {
-                item.active = false;
-            }),
-            currentItemId: ""
-        }),
-        default: () => state
-    });
-};
diff --git a/src/store/projects/project-create-actions.ts b/src/store/projects/project-create-actions.ts
new file mode 100644 (file)
index 0000000..76f2059
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { reset, startSubmit, stopSubmit, initialize } from 'redux-form';
+import { RootState } from '~/store/store';
+import { dialogActions } from "~/store/dialog/dialog-actions";
+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;
+    description: string;
+}
+
+export const PROJECT_CREATE_FORM_NAME = 'projectCreateFormName';
+
+export const openProjectCreateDialog = (ownerUuid: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(PROJECT_CREATE_FORM_NAME, { ownerUuid }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_CREATE_FORM_NAME, data: {} }));
+    };
+
+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(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;
+        }
+    };
diff --git a/src/store/projects/project-move-actions.ts b/src/store/projects/project-move-actions.ts
new file mode 100644 (file)
index 0000000..6e24314
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { dialogActions } from "~/store/dialog/dialog-actions";
+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 { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+
+export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName';
+
+export const openMoveProjectDialog = (resource: { name: string, uuid: string }) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        dispatch(initialize(PROJECT_MOVE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_MOVE_FORM_NAME, data: {} }));
+    };
+
+export const moveProject = (resource: MoveToFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
+        try {
+            const project = await services.projectService.get(resource.uuid);
+            const newProject = await services.projectService.update(resource.uuid, { ...project, ownerUuid: resource.ownerUuid });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+            return newProject;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'A project with the same name already exists in the target project.' }));
+            } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
+                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' }));
+            } else {
+                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+                throw new Error('Could not move the project.');
+            }
+            return;
+        }
+    };
diff --git a/src/store/projects/project-update-actions.ts b/src/store/projects/project-update-actions.ts
new file mode 100644 (file)
index 0000000..39b97b2
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { initialize, startSubmit, stopSubmit } from 'redux-form';
+import { RootState } from "~/store/store";
+import { dialogActions } from "~/store/dialog/dialog-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';
+
+export interface ProjectUpdateFormDialogData {
+    uuid: string;
+    name: string;
+    description: string;
+}
+
+export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName';
+
+export const openProjectUpdateDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch) => {
+        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {} }));
+    };
+
+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(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..10c82ff
--- /dev/null
@@ -0,0 +1,41 @@
+// 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)
+            .reduce((resources, id) => {
+                const resource = getResource(id)(state);
+                return resource
+                    ? [...resources, resource]
+                    : resources;
+            }, [])
+            .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 56785e2..0000000
+++ /dev/null
@@ -1,112 +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";
-import { columns as trashPanelColumns } from "~/views/trash-panel/trash-panel";
-import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
-
-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({ itemId: 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"));
-            dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
-            dispatch(trashPanelActions.RESET_PAGINATION());
-            dispatch(trashPanelActions.REQUEST_ITEMS());
-        }
-    }
-];
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 febaf933d0e099763be33a25cfc75299536c4ce5..584d05e923fd55d8f443be7512c7f16f88234c26 100644 (file)
@@ -3,77 +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 { ServiceRepository } from "~/services/services";
 import { treePickerReducer } from './tree-picker/tree-picker-reducer';
-import { TreePicker } from './tree-picker/tree-picker';
-import { TrashPanelMiddlewareService } from "~/store/trash-panel/trash-panel-middleware-service";
-import { TRASH_PANEL_ID } from "~/store/trash-panel/trash-panel-action";
+import { resourcesReducer } from '~/store/resources/resources-reducer';
+import { propertiesReducer } from './properties/properties-reducer';
+import { RootState } from './store';
+import { fileUploaderReducer } from './file-uploader/file-uploader-reducer';
 
 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)
@@ -95,3 +61,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),
+    collectionPanel: collectionPanelReducer,
+    collectionPanelFiles: collectionPanelFilesReducer,
+    contextMenu: contextMenuReducer,
+    dataExplorer: dataExplorerReducer,
+    detailsPanel: detailsPanelReducer,
+    dialog: dialogReducer,
+    favorites: favoritesReducer,
+    form: formReducer,
+    properties: propertiesReducer,
+    resources: resourcesReducer,
+    router: routerReducer,
+    snackbar: snackbarReducer,
+    treePicker: treePickerReducer,
+    fileUploader: fileUploaderReducer,
+});
index e3bebe1c858f6e9a6ef7017de35b6162e72a745d..5b04389af6850888a3bad224dc11fc507005d5e3 100644 (file)
@@ -2,17 +2,17 @@
 //
 // 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";
 
 export const treePickerActions = unionize({
-    LOAD_TREE_PICKER_NODE: ofType<{ id: string }>(),
-    LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreePickerNode> }>(),
-    TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string }>(),
-    TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ id: string }>()
-}, {
-        tag: 'type',
-        value: 'payload'
-    });
+    LOAD_TREE_PICKER_NODE: ofType<{ nodeId: string, pickerId: string }>(),
+    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 }>()
+});
 
 export type TreePickerAction = UnionOf<typeof treePickerActions>;
index 3248cb2efba7f06af804c0b4f6a169de02170a67..e09d12d777a485199325da74c146d76f6da375e6 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTree, getNodeValue, getNodeChildrenIds } from "~/models/tree";
 import { TreePickerNode, createTreePickerNode } from "./tree-picker";
 import { treePickerReducer } from "./tree-picker-reducer";
 import { treePickerActions } from "./tree-picker-actions";
@@ -11,89 +11,95 @@ import { TreeItemStatus } from "~/components/tree/tree";
 describe('TreePickerReducer', () => {
     it('LOAD_TREE_PICKER_NODE - initial state', () => {
         const tree = createTree<TreePickerNode>();
-        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' }));
-        expect(newTree).toEqual(tree);
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" }));
+        expect(newState).toEqual({ 'projects': tree });
     });
 
     it('LOAD_TREE_PICKER_NODE', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" })));
+
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             status: TreeItemStatus.PENDING
         });
     });
 
     it('LOAD_TREE_PICKER_NODE_SUCCESS - initial state', () => {
-        const tree = createTree<TreePickerNode>();
-        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
-        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode] }));
-        expect(getNodeChildren('')(newTree)).toEqual(['1.1']);
+        const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [subNode], pickerId: "projects" }));
+        expect(getNodeChildrenIds('')(newState.projects)).toEqual(['1.1']);
     });
 
     it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode] })));
-        expect(getNodeChildren('1')(newTree)).toEqual(['1.1']);
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '1', nodes: [subNode], pickerId: "projects" })));
+        expect(getNodeChildrenIds('1')(newState.projects)).toEqual(['1.1']);
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             status: TreeItemStatus.LOADED
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - collapsed', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             collapsed: false
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             collapsed: true
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_SELECT - selected', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             selected: true
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_SELECT - not selected', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             selected: false
         });
     });
index 8d61714cc9f744929587dd7f96f613ba18120f67..b0d9bc94caa9843d6774b141ea8687737f773240 100644 (file)
@@ -2,28 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues } from "~/models/tree";
+import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues, Tree } from "~/models/tree";
 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 = createTree(), action: TreePickerAction) =>
+export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
     treePickerActions.match(action, {
-        LOAD_TREE_PICKER_NODE: ({ id }) =>
-            setNodeValueWith(setPending)(id)(state),
-        LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes }) => {
-            const [newState] = [state]
-                .map(receiveNodes(nodes)(id))
-                .map(setNodeValueWith(setLoaded)(id));
-            return newState;
-        },
-        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id }) =>
-            setNodeValueWith(toggleCollapse)(id)(state),
-        TOGGLE_TREE_PICKER_NODE_SELECT: ({ id }) =>
-            mapTreeValues(toggleSelect(id))(state),
+        LOAD_TREE_PICKER_NODE: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, setNodeValueWith(setPending)(nodeId)),
+        LOAD_TREE_PICKER_NODE_SUCCESS: ({ nodeId, nodes, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(nodeId), setNodeValueWith(setLoaded)(nodeId))),
+        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, setNodeValueWith(toggleCollapse)(nodeId)),
+        TOGGLE_TREE_PICKER_NODE_SELECT: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, mapTreeValues(toggleSelect(nodeId))),
+        RESET_TREE_PICKER: ({ pickerId }) =>
+            updateOrCreatePicker(state, pickerId, createTree),
+        EXPAND_TREE_PICKER_NODES: ({ pickerId, nodeIds }) =>
+            updateOrCreatePicker(state, pickerId, mapTreeValues(expand(nodeIds))),
         default: () => state
     });
 
+const updateOrCreatePicker = (state: TreePicker, pickerId: string, func: (value: Tree<TreePickerNode>) => Tree<TreePickerNode>) => {
+    const picker = state[pickerId] || createTree();
+    const updatedPicker = func(picker);
+    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 });
 
@@ -33,20 +46,28 @@ const setLoaded = (value: TreePickerNode): TreePickerNode =>
 const toggleCollapse = (value: TreePickerNode): TreePickerNode =>
     ({ ...value, collapsed: !value.collapsed });
 
-const toggleSelect = (id: string) => (value: TreePickerNode): TreePickerNode =>
-    value.id === id
+const toggleSelect = (nodeId: string) => (value: TreePickerNode): TreePickerNode =>
+    value.nodeId === nodeId
         ? ({ ...value, selected: !value.selected })
         : ({ ...value, selected: false });
 
-const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: TreePicker) =>
-    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: [],
-    id: node.id,
+    id: node.nodeId,
     parent,
     value: node
 });
index e19ce3a7a48c0d162c1fb462c00ddb26d8b30c57..259a4b8d53de78e1b7d9992ae85b4d69d5fe40ca 100644 (file)
@@ -5,19 +5,21 @@
 import { Tree } from "~/models/tree";
 import { TreeItemStatus } from "~/components/tree/tree";
 
-export type TreePicker = Tree<TreePickerNode>;
+export type TreePicker = { [key: string]: Tree<TreePickerNode> };
 
-export interface TreePickerNode {
-    id: string;
-    value: any;
+export interface TreePickerNode<Value = any> {
+    nodeId: string;
+    value: Value;
     selected: boolean;
     collapsed: boolean;
     status: TreeItemStatus;
 }
 
-export const createTreePickerNode = (data: {id: string, value: any}) => ({
+export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({
     ...data,
     selected: false,
     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..3c68c00
--- /dev/null
@@ -0,0 +1,207 @@
+// 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';
+import * as processesActions from '../processes/processes-actions';
+import { getProcess } from '../processes/process';
+
+
+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 loadProcess = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        await dispatch<any>(processesActions.loadProcess(uuid));
+        const process = getProcess(uuid)(getState().resources);
+        if (process) {
+            await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
+            dispatch<any>(setCollectionBreadcrumbs(process.containerRequest.ownerUuid));
+            dispatch(loadDetailsPanel(uuid));
+        }
+    };
+
+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 edd07822942ace10ac40ae68e79b9222422dbe55..95edadfdd1809b23e83aff5ec47043f761b9e30f 100644 (file)
@@ -13,4 +13,9 @@ export const PROJECT_DESCRIPTION_VALIDATION = [maxLength(255)];
 
 export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
 export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
-export const COLLECTION_PROJECT_VALIDATION = [require];
\ No newline at end of file
+export const COLLECTION_PROJECT_VALIDATION = [require];
+
+export const COPY_NAME_VALIDATION = [require, maxLength(255)];
+export const COPY_FILE_VALIDATION = [require];
+
+export const MOVE_TO_VALIDATION = [require];
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 ae9b53e33034355dcafd0f48013f9ad761c78656..ccb18c8f98d715eac025855d502a2c03bc021880 100644 (file)
@@ -10,10 +10,11 @@ import { CollectionPanelFilesState, CollectionPanelDirectory, CollectionPanelFil
 import { FileTreeData } from "~/components/file-tree/file-tree-data";
 import { Dispatch } from "redux";
 import { collectionPanelFilesAction } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
-import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
 import { ContextMenuKind } from "../context-menu/context-menu";
-import { Tree, getNodeChildren, getNode } from "~/models/tree";
+import { Tree, getNodeChildrenIds, getNode } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
+import { openContextMenu } from '~/store/context-menu/context-menu-actions';
+import { openUploadCollectionFilesDialog } from '~/store/collections/collection-upload-actions';
 
 const memoizedMapStateToProps = () => {
     let prevState: CollectionPanelFilesState;
@@ -22,7 +23,7 @@ const memoizedMapStateToProps = () => {
     return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
         if (prevState !== state.collectionPanelFiles) {
             prevState = state.collectionPanelFiles;
-            prevTree = getNodeChildren('')(state.collectionPanelFiles)
+            prevTree = getNodeChildrenIds('')(state.collectionPanelFiles)
                 .map(collectionItemToTreeItem(state.collectionPanelFiles));
         }
         return {
@@ -32,7 +33,9 @@ const memoizedMapStateToProps = () => {
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
-    onUploadDataClick: () => { return; },
+    onUploadDataClick: () => {
+        dispatch<any>(openUploadCollectionFilesDialog());
+    },
     onCollapseToggle: (id) => {
         dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
     },
@@ -40,17 +43,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: '' }
-        }))
 });
 
 
@@ -77,7 +74,7 @@ const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | Collecti
                 type: node.value.type
             },
             id: node.id,
-            items: getNodeChildren(node.id)(tree)
+            items: getNodeChildrenIds(node.id)(tree)
                 .map(collectionItemToTreeItem(tree)),
             open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
             selected: node.value.selected,
index c8fb3cbc927d7c51f1f93fcea2b12ea407e1fd0d..f26003653382701cf8b700143bd30db830788c46 100644 (file)
@@ -6,8 +6,10 @@ import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RemoveIcon } from "~/components/icon/icon";
-import { openUpdater } from "~/store/collections/updater/collection-updater-action";
+import { openCollectionUpdateDialog } from "~/store/collections/collection-update-actions";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openMoveCollectionDialog } from '~/store/collections/collection-move-actions';
+import { openCollectionCopyDialog } from "~/store/collections/collection-copy-actions";
 import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleCollectionTrashed } from "~/store/collections/collection-trash-actions";
 
@@ -16,7 +18,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         icon: RenameIcon,
         name: "Edit collection",
         execute: (dispatch, resource) => {
-            dispatch<any>(openUpdater(resource));
+            dispatch<any>(openCollectionUpdateDialog(resource));
         }
     },
     {
@@ -29,9 +31,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
     {
         icon: MoveToIcon,
         name: "Move to",
-        execute: (dispatch, resource) => {
-            // add code
-        }
+        execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
     },
     {
         component: ToggleFavoriteAction,
@@ -51,7 +51,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         icon: CopyIcon,
         name: "Copy to project",
         execute: (dispatch, resource) => {
-            // add code
+            dispatch<any>(openCollectionCopyDialog(resource));
         }
     },
     {
index 653da011f7ed03153cfd3a360c192bf0b36d146c..5c4dab30e537f86ad4b079bf71e92339c1da895b 100644 (file)
@@ -4,8 +4,7 @@
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
-import { createCollectionWithSelected } from "~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected";
-
+import { openCollectionPartialCopyDialog } from '~/store/collections/collection-partial-copy-actions';
 
 export const collectionFilesActionSet: ContextMenuActionSet = [[{
     name: "Select all",
@@ -30,6 +29,6 @@ export const collectionFilesActionSet: ContextMenuActionSet = [[{
 }, {
     name: "Create a new collection with selected",
     execute: (dispatch) => {
-        dispatch<any>(createCollectionWithSelected());
+        dispatch<any>(openCollectionPartialCopyDialog());
     }
 }]];
index a3bfa0b95cb30736ae8c995fcfa00e3deb463b0f..b55648917329723d6cec917b8e73b53725695673 100644 (file)
@@ -3,17 +3,16 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { RenameIcon, DownloadIcon, RemoveIcon } from "~/components/icon/icon";
-import { openRenameFileDialog } from "../../rename-file-dialog/rename-file-dialog";
+import { RenameIcon, RemoveIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 
 
 export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
     name: "Rename",
     icon: RenameIcon,
     execute: (dispatch, resource) => {
-        dispatch<any>(openRenameFileDialog(resource.name));
+        dispatch<any>(openRenameFileDialog({ name: resource.name, id: resource.uuid }));
     }
 }, {
     component: DownloadCollectionFileAction,
index dbc9e23698b1d3814889d814bf98adb69b94b837..a1df8385a4e1ad1a4a369412c5815d31a9cebb83 100644 (file)
@@ -4,19 +4,20 @@
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, RemoveIcon } from "~/components/icon/icon";
-import { openUpdater } from "~/store/collections/updater/collection-updater-action";
+import { openCollectionUpdateDialog } from "~/store/collections/collection-update-actions";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
-import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
-import { toggleCollectionTrashed } from "~/store/collections/collection-trash-actions";
+import { openMoveCollectionDialog } from '~/store/collections/collection-move-actions';
+import { openCollectionCopyDialog } from '~/store/collections/collection-copy-actions';
 
 export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
         icon: RenameIcon,
         name: "Edit collection",
         execute: (dispatch, resource) => {
-            dispatch<any>(openUpdater(resource));
+            dispatch<any>(openCollectionUpdateDialog(resource));
         }
     },
     {
@@ -29,9 +30,7 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
         icon: MoveToIcon,
         name: "Move to",
-        execute: (dispatch, resource) => {
-            // add code
-        }
+        execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
     },
     {
         component: ToggleFavoriteAction,
@@ -51,8 +50,8 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
         icon: CopyIcon,
         name: "Copy to project",
         execute: (dispatch, resource) => {
-            // add code
-        }
+            dispatch<any>(openCollectionCopyDialog(resource));
+        },
     },
     {
         icon: DetailsIcon,
diff --git a/src/views-components/context-menu/action-sets/process-action-set.ts b/src/views-components/context-menu/action-sets/process-action-set.ts
new file mode 100644 (file)
index 0000000..5d679f5
--- /dev/null
@@ -0,0 +1,111 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { toggleFavorite } from "~/store/favorites/favorites-actions";
+import {
+    RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon,
+    AdvancedIcon, RemoveIcon, ReRunProcessIcon, LogIcon
+} from "~/components/icon/icon";
+import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+
+export const processActionSet: ContextMenuActionSet = [[
+    {
+        icon: RenameIcon,
+        name: "Edit process",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: ShareIcon,
+        name: "Share",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: MoveToIcon,
+        name: "Move to",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        component: ToggleFavoriteAction,
+        execute: (dispatch, resource) => {
+            dispatch<any>(toggleFavorite(resource)).then(() => {
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+            });
+        }
+    },
+    {
+        icon: CopyIcon,
+        name: "Copy to project",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: ReRunProcessIcon,
+        name: "Re-run process",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        name: "Inputs",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        name: "Outputs",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        name: "Command",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: DetailsIcon,
+        name: "View details",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: LogIcon,
+        name: "Log",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: ProvenanceGraphIcon,
+        name: "Provenance graph",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: AdvancedIcon,
+        name: "Advanced",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: RemoveIcon,
+        name: "Remove",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    }
+]];
index de3b954f4c1c819c5d9aab4a136cd0e03a377a6a..386c5162f3c6ab9858c55f9b25468acbd98b353c 100644 (file)
@@ -2,30 +2,24 @@
 //
 // 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 { collectionCreateActions } from "~/store/collections/creator/collection-creator-action";
-import { PROJECT_CREATE_DIALOG } from "../../dialog-create/dialog-project-create";
-import { COLLECTION_CREATE_DIALOG } from "../../dialog-create/dialog-collection-create";
+import { openCollectionCreateDialog } from '~/store/collections/collection-create-actions';
 import { NewProjectIcon, CollectionIcon } from "~/components/icon/icon";
+import { openProjectCreateDialog } from '~/store/projects/project-create-actions';
 
 export const rootProjectActionSet: ContextMenuActionSet =  [[
     {
         icon: NewProjectIcon,
         name: "New project",
         execute: (dispatch, resource) => {
-            dispatch(reset(PROJECT_CREATE_DIALOG));
-            dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: resource.uuid }));
+            dispatch<any>(openProjectCreateDialog(resource.uuid));
         }
     },
     {
         icon: CollectionIcon,
         name: "New Collection",
         execute: (dispatch, resource) => {
-            dispatch(reset(COLLECTION_CREATE_DIALOG));
-            dispatch(collectionCreateActions.OPEN_COLLECTION_CREATOR({ ownerUuid: resource.uuid }));
+            dispatch<any>(openCollectionCreateDialog(resource.uuid));
         }
     }
 ]];
index 8036bb572859ee90a11fa94474a328ec69aca07f..5d94766c447674170560e45b5392993ef505a977 100644 (file)
@@ -63,5 +63,6 @@ export enum ContextMenuKind {
     COLLECTION_FILES = "CollectionFiles",
     COLLECTION_FILES_ITEM = "CollectionFilesItem",
     COLLECTION = 'Collection',
-    COLLECTION_RESOURCE = 'CollectionResource'
+    COLLECTION_RESOURCE = 'CollectionResource',
+    PROCESS = "Process"
 }
diff --git a/src/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected.tsx b/src/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected.tsx
deleted file mode 100644 (file)
index 46bc724..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { Dispatch } from "redux";
-import { reduxForm, reset, startSubmit, stopSubmit } from "redux-form";
-import { withDialog } from "~/store/dialog/with-dialog";
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { DialogCollectionCreateWithSelected } from "../dialog-create/dialog-collection-create-selected";
-import { loadProjectTreePickerProjects } from "../project-tree-picker/project-tree-picker";
-
-export const DIALOG_COLLECTION_CREATE_WITH_SELECTED = 'dialogCollectionCreateWithSelected';
-
-export const createCollectionWithSelected = () =>
-    (dispatch: Dispatch) => {
-        dispatch(reset(DIALOG_COLLECTION_CREATE_WITH_SELECTED));
-        dispatch<any>(loadProjectTreePickerProjects(''));
-        dispatch(dialogActions.OPEN_DIALOG({ id: DIALOG_COLLECTION_CREATE_WITH_SELECTED, data: {} }));
-    };
-
-export const [DialogCollectionCreateWithSelectedFile] = [DialogCollectionCreateWithSelected]
-    .map(withDialog(DIALOG_COLLECTION_CREATE_WITH_SELECTED))
-    .map(reduxForm({
-        form: DIALOG_COLLECTION_CREATE_WITH_SELECTED,
-        onSubmit: (data, dispatch) => {
-            dispatch(startSubmit(DIALOG_COLLECTION_CREATE_WITH_SELECTED));
-            setTimeout(() => dispatch(stopSubmit(DIALOG_COLLECTION_CREATE_WITH_SELECTED, { name: 'Invalid name' })), 2000);
-        }
-    }));
diff --git a/src/views-components/create-collection-dialog/create-collection-dialog.tsx b/src/views-components/create-collection-dialog/create-collection-dialog.tsx
deleted file mode 100644 (file)
index 94eb82f..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { connect } from "react-redux";
-import { Dispatch } from "redux";
-import { SubmissionError } from "redux-form";
-
-import { RootState } from "~/store/store";
-import { DialogCollectionCreate } from "../dialog-create/dialog-collection-create";
-import { collectionCreateActions, createCollection } from "~/store/collections/creator/collection-creator-action";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
-import { UploadFile } from "~/store/collections/uploader/collection-uploader-actions";
-import { projectPanelActions } from "~/store/project-panel/project-panel-action";
-
-const mapStateToProps = (state: RootState) => ({
-    open: state.collections.creator.opened
-});
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
-    handleClose: () => {
-        dispatch(collectionCreateActions.CLOSE_COLLECTION_CREATOR());
-    },
-    onSubmit: (data: { name: string, description: string }, files: UploadFile[]) => {
-        return dispatch<any>(addCollection(data, files.map(f => f.file)))
-            .catch((e: any) => {
-                throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
-            });
-    }
-});
-
-const addCollection = (data: { name: string, description: string }, files: File[]) =>
-    (dispatch: Dispatch) => {
-        return dispatch<any>(createCollection(data, files)).then(() => {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: "Collection has been successfully created.",
-                hideDuration: 2000
-            }));
-            dispatch(projectPanelActions.REQUEST_ITEMS());
-        });
-    };
-
-export const CreateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionCreate);
-
diff --git a/src/views-components/create-project-dialog/create-project-dialog.tsx b/src/views-components/create-project-dialog/create-project-dialog.tsx
deleted file mode 100644 (file)
index 43f56ed..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { connect } from "react-redux";
-import { Dispatch } from "redux";
-import { SubmissionError } from "redux-form";
-
-import { RootState } from "~/store/store";
-import { DialogProjectCreate } from "../dialog-create/dialog-project-create";
-import { projectActions, createProject, getProjectList } from "~/store/project/project-action";
-import { projectPanelActions } from "~/store/project-panel/project-panel-action";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
-
-const mapStateToProps = (state: RootState) => ({
-    open: state.projects.creator.opened
-});
-
-const addProject = (data: { name: string, description: string }) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const { ownerUuid } = getState().projects.creator;
-        return dispatch<any>(createProject(data)).then(() => {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: "Project has been successfully created.",
-                hideDuration: 2000
-            }));
-            dispatch(projectPanelActions.REQUEST_ITEMS());
-            dispatch<any>(getProjectList(ownerUuid));
-        });
-    };
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
-    handleClose: () => {
-        dispatch(projectActions.CLOSE_PROJECT_CREATOR());
-    },
-    onSubmit: (data: { name: string, description: string }) => {
-        return dispatch<any>(addProject(data))
-            .catch((e: any) => {
-                throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Project with this name already exists." : "" });
-            });
-    }
-});
-
-export const CreateProjectDialog = connect(mapStateToProps, mapDispatchToProps)(DialogProjectCreate);
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 8246d02c37d6cdf8338359537140f90050126256..390e601f9dbd8bac5e74967f316db3211c35163f 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 c41e0b80ee1616af2b97f20d3d5c71e2311b0288..721418ef5463c5f043cae43c4a71b4b94db4242d 100644 (file)
@@ -21,7 +21,7 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
         return <div>
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.COLLECTION)} />
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
             <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
             {/* Links but we dont have view */}
index a298d670ee70de01e14ab58b03c9410aaccd4e69..7aae7860ac39999df6262fdb886198f49400a4e2 100644 (file)
@@ -20,8 +20,9 @@ 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' | 'tabContainer';
+type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'headerTitle' | 'tabContainer';
 
 const drawerWidth = 320;
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
@@ -45,7 +46,11 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         textAlign: 'center'
     },
     headerIcon: {
-        fontSize: "34px"
+        fontSize: '2.125rem'
+    },
+    headerTitle: {
+        overflowWrap: 'break-word',
+        wordWrap: 'break-word'
     },
     tabContainer: {
         padding: theme.spacing.unit * 3
@@ -66,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: () => {
@@ -106,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'>
@@ -114,20 +122,20 @@ export const DetailsPanel = withStyles(styles)(
                                         {item.getIcon(classes.headerIcon)}
                                     </Grid>
                                     <Grid item xs={8}>
-                                        <Typography variant="title">
+                                        <Typography variant="title" className={classes.headerTitle}>
                                             {item.getTitle()}
                                         </Typography>
                                     </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">
@@ -135,7 +143,7 @@ export const DetailsPanel = withStyles(styles)(
                                 </Grid>
                             )}
                             {tabsValue === 1 && this.renderTabContainer(
-                                <Grid container direction="column"/>
+                                <Grid container direction="column" />
                             )}
                         </Drawer>
                     </Typography>
index dee6e8b0b9a20db979ca64cddda58b268c2535d1..e3c9823d018b62d1a0b247007cca32f158230c87 100644 (file)
@@ -21,7 +21,7 @@ export class ProcessDetails extends DetailsData<ProcessResource> {
         return <div>
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
 
             {/* Missing attr */}
             <DetailsAttribute label='Status' value={this.item.state} />
@@ -34,10 +34,10 @@ export class ProcessDetails extends DetailsData<ProcessResource> {
             {/* Links but we dont have view */}
             <DetailsAttribute label='Outputs' link={this.item.outputPath} value={this.item.outputPath} />
             <DetailsAttribute label='UUID' link={this.item.uuid} value={this.item.uuid} />
-            <DetailsAttribute label='Container UUID' link={this.item.containerUuid} value={this.item.containerUuid} />
+            <DetailsAttribute label='Container UUID' link={this.item.containerUuid || ''} value={this.item.containerUuid} />
 
             <DetailsAttribute label='Priority' value={this.item.priority} />
-            <DetailsAttribute label='Runtime Constraints' value={this.item.runtimeConstraints} />
+            <DetailsAttribute label='Runtime Constraints' value={JSON.stringify(this.item.runtimeConstraints)} />
             {/* Link but we dont have view */}
             <DetailsAttribute label='Docker Image locator' link={this.item.containerImage} value={this.item.containerImage} />
         </div>;
index 154f0a2c906e908660d05d5d437c65ad7890911a..1e65ec834bf8e48f75230bea8505d843eda82a63 100644 (file)
@@ -22,7 +22,7 @@ export class ProjectDetails extends DetailsData<ProjectResource> {
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
             {/* Missing attr */}
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
             <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
             {/* Missing attr */}
diff --git a/src/views-components/dialog-copy/dialog-collection-copy.tsx b/src/views-components/dialog-copy/dialog-collection-copy.tsx
new file mode 100644 (file)
index 0000000..029db57
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { ProjectTreePickerField } from '~/views-components/project-tree-picker/project-tree-picker';
+import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from '~/validators/validators';
+import { TextField } from "~/components/text-field/text-field";
+import { CollectionCopyFormDialogData } from "~/store/collections/collection-copy-actions";
+
+type CopyFormDialogProps = WithDialogProps<string> & InjectedFormProps<CollectionCopyFormDialogData>;
+
+export const DialogCollectionCopy = (props: CopyFormDialogProps) =>
+    <FormDialog
+        dialogTitle='Make a copy'
+        formFields={CollectionCopyFields}
+        submitLabel='Copy'
+        {...props}
+    />;
+
+const CollectionCopyFields = () => <span>
+    <Field
+        name='name'
+        component={TextField}
+        validate={COPY_NAME_VALIDATION}
+        label="Enter a new name for the copy" />
+    <Field
+        name="ownerUuid"
+        component={ProjectTreePickerField}
+        validate={COPY_FILE_VALIDATION} />
+</span>;
diff --git a/src/views-components/dialog-copy/dialog-collection-partial-copy.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy.tsx
new file mode 100644 (file)
index 0000000..7fc301f
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from '~/views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialCopyFormData } from '~/store/collections/collection-partial-copy-actions';
+
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyFormData>;
+
+export const DialogCollectionPartialCopy = (props: DialogCollectionPartialCopyProps) =>
+    <FormDialog
+        dialogTitle='Create a collection'
+        formFields={CollectionPartialCopyFields}
+        submitLabel='Create a collection'
+        {...props}
+    />;
+
+export const CollectionPartialCopyFields = () => <div style={{ display: 'flex' }}>
+    <div>
+        <CollectionNameField />
+        <CollectionDescriptionField />
+    </div>
+    <CollectionProjectPickerField />
+</div>;
diff --git a/src/views-components/dialog-create/dialog-collection-create-selected.tsx b/src/views-components/dialog-create/dialog-collection-create-selected.tsx
deleted file mode 100644 (file)
index af2536d..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from "react";
-import { InjectedFormProps, Field, WrappedFieldProps } from "redux-form";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress } from "@material-ui/core";
-import { WithDialogProps } from "~/store/dialog/with-dialog";
-import { TextField } from "~/components/text-field/text-field";
-import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
-import { ProjectTreePicker } from "../project-tree-picker/project-tree-picker";
-
-export const DialogCollectionCreateWithSelected = (props: WithDialogProps<string> & InjectedFormProps<{ name: string }>) =>
-    <form>
-        <Dialog open={props.open}
-            disableBackdropClick={true}
-            disableEscapeKeyDown={true}>
-            <DialogTitle>Create a collection</DialogTitle>
-            <DialogContent style={{ display: 'flex' }}>
-                <div>
-                    <Field
-                        name='name'
-                        component={TextField}
-                        validate={COLLECTION_NAME_VALIDATION}
-                        label="Collection Name" />
-                    <Field
-                        name='description'
-                        component={TextField}
-                        validate={COLLECTION_DESCRIPTION_VALIDATION}
-                        label="Description - optional" />
-                </div>
-                <Field
-                    name="projectUuid"
-                    component={Picker}
-                    validate={COLLECTION_PROJECT_VALIDATION} />
-            </DialogContent>
-            <DialogActions>
-                <Button
-                    variant='flat'
-                    color='primary'
-                    disabled={props.submitting}
-                    onClick={props.closeDialog}>
-                    Cancel
-                    </Button>
-                <Button
-                    variant='contained'
-                    color='primary'
-                    type='submit'
-                    onClick={props.handleSubmit}
-                    disabled={props.pristine || props.invalid || props.submitting}>
-                    {props.submitting
-                        ? <CircularProgress size={20} />
-                        : 'Create a collection'}
-                </Button>
-            </DialogActions>
-        </Dialog>
-    </form>;
-
-const Picker = (props: WrappedFieldProps) =>
-    <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
-    </div>;
index af0e33f1b4260fab01a15bef29fef1985701ecdd..38a8114de2856d4d38ca739f549ef3386831e1c2 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { reduxForm, Field } from 'redux-form';
-import { compose } from 'redux';
-import { TextField } from '~/components/text-field/text-field';
-import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
-import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField } from '~/views-components/form-fields/collection-form-fields';
+import { require } from '~/validators/require';
+import { FileUploaderField } from '../file-uploader/file-uploader';
+
+
+type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<CollectionCreateFormDialogData>;
+
+export const DialogCollectionCreate = (props: DialogCollectionProps) =>
+    <FormDialog
+        dialogTitle='Create a collection'
+        formFields={CollectionAddFields}
+        submitLabel='Create a Collection'
+        {...props}
+    />;
+
+const CollectionAddFields = () => <span>
+    <CollectionNameField />
+    <CollectionDescriptionField />
+    <Field
+        name='files'
+        validate={[require]}
+        label='Files'
+        component={FileUploaderField} />
+</span>;
 
-import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '~/validators/validators';
-import { FileUpload } from "~/components/file-upload/file-upload";
-import { connect, DispatchProp } from "react-redux";
-import { RootState } from "~/store/store";
-import { collectionUploaderActions, UploadFile } from "~/store/collections/uploader/collection-uploader-actions";
-
-type CssRules = "button" | "lastButton" | "formContainer" | "createProgress" | "dialogActions";
-
-const styles: StyleRulesCallback<CssRules> = theme => ({
-    button: {
-        marginLeft: theme.spacing.unit
-    },
-    lastButton: {
-        marginLeft: theme.spacing.unit,
-        marginRight: "20px",
-    },
-    formContainer: {
-        display: "flex",
-        flexDirection: "column",
-    },
-    createProgress: {
-        position: "absolute",
-        minWidth: "20px",
-        right: "110px"
-    },
-    dialogActions: {
-        marginBottom: theme.spacing.unit * 3
-    }
-});
-
-interface DialogCollectionDataProps {
-    open: boolean;
-    handleSubmit: any;
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-    files: UploadFile[];
-}
-
-interface DialogCollectionActionProps {
-    handleClose: () => void;
-    onSubmit: (data: { name: string, description: string }, files: UploadFile[]) => void;
-}
-
-type DialogCollectionProps = DialogCollectionDataProps & DialogCollectionActionProps & DispatchProp & WithStyles<CssRules>;
-
-export const COLLECTION_CREATE_DIALOG = "collectionCreateDialog";
-
-export const DialogCollectionCreate = compose(
-    connect((state: RootState) => ({
-        files: state.collections.uploader
-    })),
-    reduxForm({ form: COLLECTION_CREATE_DIALOG }),
-    withStyles(styles))(
-    class DialogCollectionCreate extends React.Component<DialogCollectionProps> {
-            render() {
-                const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine, files } = this.props;
-                const busy = submitting || files.reduce(
-                    (prev, curr) => prev + (curr.loaded > 0 && curr.loaded < curr.total ? 1 : 0), 0
-                ) > 0;
-                return (
-                    <Dialog
-                        open={open}
-                        onClose={handleClose}
-                        fullWidth={true}
-                        maxWidth='sm'
-                        disableBackdropClick={true}
-                        disableEscapeKeyDown={true}>
-                        <form onSubmit={handleSubmit((data: any) => onSubmit(data, files))}>
-                            <DialogTitle id="form-dialog-title">Create a collection</DialogTitle>
-                            <DialogContent className={classes.formContainer}>
-                                <Field name="name"
-                                    disabled={submitting}
-                                    component={TextField}
-                                    validate={COLLECTION_NAME_VALIDATION}
-                                    label="Collection Name" />
-                                <Field name="description"
-                                    disabled={submitting}
-                                    component={TextField}
-                                    validate={COLLECTION_DESCRIPTION_VALIDATION}
-                                    label="Description - optional" />
-                                <FileUpload
-                                    files={files}
-                                    disabled={busy}
-                                    onDrop={files => this.props.dispatch(collectionUploaderActions.SET_UPLOAD_FILES(files))} />
-                            </DialogContent>
-                            <DialogActions className={classes.dialogActions}>
-                                <Button onClick={handleClose} className={classes.button} color="primary"
-                                    disabled={busy}>CANCEL</Button>
-                                <Button type="submit"
-                                    className={classes.lastButton}
-                                    color="primary"
-                                    disabled={invalid || busy || pristine}
-                                    variant="contained">
-                                    CREATE A COLLECTION
-                            </Button>
-                                {busy && <CircularProgress size={20} className={classes.createProgress} />}
-                            </DialogActions>
-                        </form>
-                    </Dialog>
-                );
-            }
-        }
-    );
index e77114b369a2137d5cdb3584703c68e5163e45f3..8c1d3800a0e1b275d9a12a3d02bfe2cf87828b1d 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { reduxForm, Field } from 'redux-form';
-import { compose } from 'redux';
-import { TextField } from '~/components/text-field/text-field';
-import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
-import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { ProjectCreateFormDialogData } from '~/store/projects/project-create-actions';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { ProjectNameField, ProjectDescriptionField } from '~/views-components/form-fields/project-form-fields';
 
-import { PROJECT_NAME_VALIDATION, PROJECT_DESCRIPTION_VALIDATION } from '~/validators/validators';
+type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<ProjectCreateFormDialogData>;
 
-type CssRules = "button" | "lastButton" | "formContainer" | "dialog" | "dialogTitle" | "createProgress" | "dialogActions";
+export const DialogProjectCreate = (props: DialogCollectionProps) =>
+    <FormDialog
+        dialogTitle='Create a project'
+        formFields={ProjectAddFields}
+        submitLabel='Create a Project'
+        {...props}
+    />;
 
-const styles: StyleRulesCallback<CssRules> = theme => ({
-    button: {
-        marginLeft: theme.spacing.unit
-    },
-    lastButton: {
-        marginLeft: theme.spacing.unit,
-        marginRight: "20px",
-    },
-    formContainer: {
-        display: "flex",
-        flexDirection: "column",
-        marginTop: "20px",
-    },
-    dialogTitle: {
-        paddingBottom: "0"
-    },
-    dialog: {
-        minWidth: "600px",
-        minHeight: "320px"
-    },
-    createProgress: {
-        position: "absolute",
-        minWidth: "20px",
-        right: "95px"
-    },
-    dialogActions: {
-        marginBottom: "24px"
-    }
-});
-interface DialogProjectProps {
-    open: boolean;
-    handleClose: () => void;
-    onSubmit: (data: { name: string, description: string }) => void;
-    handleSubmit: any;
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-export const PROJECT_CREATE_DIALOG = "projectCreateDialog";
-
-export const DialogProjectCreate = compose(
-    reduxForm({ form: PROJECT_CREATE_DIALOG }),
-    withStyles(styles))(
-    class DialogProjectCreate extends React.Component<DialogProjectProps & WithStyles<CssRules>> {
-        render() {
-            const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
-
-            return (
-                <Dialog
-                    open={open}
-                    onClose={handleClose}
-                    disableBackdropClick={true}
-                    disableEscapeKeyDown={true}>
-                    <div className={classes.dialog}>
-                        <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
-                            <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a
-                                project</DialogTitle>
-                            <DialogContent className={classes.formContainer}>
-                                <Field name="name"
-                                       component={TextField}
-                                       validate={PROJECT_NAME_VALIDATION}
-                                       label="Project Name"/>
-                                <Field name="description"
-                                       component={TextField}
-                                       validate={PROJECT_DESCRIPTION_VALIDATION}
-                                       label="Description - optional"/>
-                            </DialogContent>
-                            <DialogActions className={classes.dialogActions}>
-                                <Button onClick={handleClose} className={classes.button} color="primary"
-                                        disabled={submitting}>CANCEL</Button>
-                                <Button type="submit"
-                                        className={classes.lastButton}
-                                        color="primary"
-                                        disabled={invalid || submitting || pristine}
-                                        variant="contained">
-                                    CREATE A PROJECT
-                                </Button>
-                                {submitting && <CircularProgress size={20} className={classes.createProgress}/>}
-                            </DialogActions>
-                        </form>
-                    </div>
-                </Dialog>
-            );
-        }
-    }
-);
+const ProjectAddFields = () => <span>
+    <ProjectNameField />
+    <ProjectDescriptionField />
+</span>;
diff --git a/src/views-components/dialog-forms/copy-collection-dialog.ts b/src/views-components/dialog-forms/copy-collection-dialog.ts
new file mode 100644 (file)
index 0000000..245465f
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { withDialog } from "~/store/dialog/with-dialog";
+import { reduxForm } from 'redux-form';
+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),
+    reduxForm<CollectionCopyFormDialogData>({
+        form: COLLECTION_COPY_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollection(data));
+        }
+    })
+)(DialogCollectionCopy);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/create-collection-dialog.ts b/src/views-components/dialog-forms/create-collection-dialog.ts
new file mode 100644 (file)
index 0000000..785be78
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+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(createCollection(data));
+        }
+    })
+)(DialogCollectionCreate);
diff --git a/src/views-components/dialog-forms/create-project-dialog.ts b/src/views-components/dialog-forms/create-project-dialog.ts
new file mode 100644 (file)
index 0000000..fc9fa2b
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+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(createProject(data));
+        }
+    })
+)(DialogProjectCreate);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/files-upload-collection-dialog.ts b/src/views-components/dialog-forms/files-upload-collection-dialog.ts
new file mode 100644 (file)
index 0000000..a24490f
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions';
+import { COLLECTION_UPLOAD_FILES_DIALOG, submitCollectionFiles } from '~/store/collections/collection-upload-actions';
+import { DialogCollectionFilesUpload } from '~/views-components/dialog-upload/dialog-collection-files-upload';
+
+export const FilesUploadCollectionDialog = compose(
+    withDialog(COLLECTION_UPLOAD_FILES_DIALOG),
+    reduxForm<CollectionCreateFormDialogData>({
+        form: COLLECTION_UPLOAD_FILES_DIALOG,
+        onSubmit: (data, dispatch) => {
+            dispatch(submitCollectionFiles());
+        }
+    })
+)(DialogCollectionFilesUpload);
diff --git a/src/views-components/dialog-forms/move-collection-dialog.ts b/src/views-components/dialog-forms/move-collection-dialog.ts
new file mode 100644 (file)
index 0000000..fcdd999
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 } 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),
+    reduxForm<MoveToFormDialogData>({
+        form: COLLECTION_MOVE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(moveCollection(data));
+        }
+    })
+)(DialogMoveTo);
diff --git a/src/views-components/dialog-forms/move-project-dialog.ts b/src/views-components/dialog-forms/move-project-dialog.ts
new file mode 100644 (file)
index 0000000..c1fbb76
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { 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),
+    reduxForm<MoveToFormDialogData>({
+        form: PROJECT_MOVE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(moveProject(data));
+        }
+    })
+)(DialogMoveTo);
+
diff --git a/src/views-components/dialog-forms/partial-copy-collection-dialog.ts b/src/views-components/dialog-forms/partial-copy-collection-dialog.ts
new file mode 100644 (file)
index 0000000..16f8275
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from '~/store/dialog/with-dialog';
+import { CollectionPartialCopyFormData, copyCollectionPartial, COLLECTION_PARTIAL_COPY_FORM_NAME } from '~/store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopy } from "~/views-components/dialog-copy/dialog-collection-partial-copy";
+
+
+export const PartialCopyCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_FORM_NAME),
+    reduxForm<CollectionPartialCopyFormData>({
+        form: COLLECTION_PARTIAL_COPY_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollectionPartial(data));
+        }
+    }))(DialogCollectionPartialCopy);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/update-collection-dialog.ts b/src/views-components/dialog-forms/update-collection-dialog.ts
new file mode 100644 (file)
index 0000000..cfa5263
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { 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(updateCollection(data));
+        }
+    })
+)(DialogCollectionUpdate);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/update-project-dialog.ts b/src/views-components/dialog-forms/update-project-dialog.ts
new file mode 100644 (file)
index 0000000..36d5106
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { 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(updateProject(data));
+        }
+    })
+)(DialogProjectUpdate);
\ No newline at end of file
diff --git a/src/views-components/dialog-move/dialog-move-to.tsx b/src/views-components/dialog-move/dialog-move-to.tsx
new file mode 100644 (file)
index 0000000..425b9e4
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { ProjectTreePickerField } from '~/views-components/project-tree-picker/project-tree-picker';
+import { MOVE_TO_VALIDATION } from '~/validators/validators';
+import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+
+export const DialogMoveTo = (props: WithDialogProps<string> & InjectedFormProps<MoveToFormDialogData>) =>
+    <FormDialog
+        dialogTitle='Move to'
+        formFields={MoveToDialogFields}
+        submitLabel='Move'
+        {...props}
+    />;
+
+const MoveToDialogFields = () =>
+    <Field
+        name="ownerUuid"
+        component={ProjectTreePickerField}
+        validate={MOVE_TO_VALIDATION} />;
+
index 18c43f2d008a1cbd407410d2389e3de8532a9fd0..b98e0e840f71ae94b98de0dc99636703db22e13f 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { reduxForm, Field } from 'redux-form';
-import { compose } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { Dialog, DialogActions, DialogContent, DialogTitle, StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress } from '@material-ui/core';
-import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '~/validators/validators';
-import { COLLECTION_FORM_NAME } from '~/store/collections/updater/collection-updater-action';
-import { TextField } from '~/components/text-field/text-field';
-
-type CssRules = 'content' | 'actions' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    content: {
-        display: 'flex',
-        flexDirection: 'column'
-    },
-    actions: {
-        margin: 0,
-        padding: `${theme.spacing.unit}px ${theme.spacing.unit * 3 - theme.spacing.unit / 2}px 
-                ${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px`
-    },
-    buttonWrapper: {
-        position: 'relative'
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: 0,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
-    }
-});
-
-interface DialogCollectionDataProps {
-    open: boolean;
-    handleSubmit: any;
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface DialogCollectionAction {
-    handleClose: () => void;
-    onSubmit: (data: { name: string, description: string }) => void;
-}
-
-type DialogCollectionProps = DialogCollectionDataProps & DialogCollectionAction & WithStyles<CssRules>;
-
-export const DialogCollectionUpdate = compose(
-    reduxForm({ form: COLLECTION_FORM_NAME }),
-    withStyles(styles))(
-
-        class DialogCollectionUpdate extends React.Component<DialogCollectionProps> {
-
-            render() {
-                const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
-                return (
-                    <Dialog open={open}
-                        onClose={handleClose}
-                        fullWidth={true}
-                        maxWidth='sm'
-                        disableBackdropClick={true}
-                        disableEscapeKeyDown={true}>
-
-                        <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
-                            <DialogTitle>Edit Collection</DialogTitle>
-                            <DialogContent className={classes.content}>
-                                <Field name='name'
-                                    disabled={submitting}
-                                    component={TextField}
-                                    validate={COLLECTION_NAME_VALIDATION}
-                                    label="Collection Name" />
-                                <Field name='description'
-                                    disabled={submitting}
-                                    component={TextField}
-                                    validate={COLLECTION_DESCRIPTION_VALIDATION}
-                                    label="Description - optional" />
-                            </DialogContent>
-                            <DialogActions className={classes.actions}>
-                                <Button onClick={handleClose} color="primary"
-                                    disabled={submitting}>CANCEL</Button>
-                                <div className={classes.buttonWrapper}>
-                                    <Button type="submit" className={classes.saveButton}
-                                        color="primary"
-                                        disabled={invalid || submitting || pristine}
-                                        variant="contained">
-                                        SAVE
-                                    </Button>
-                                    {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                                </div>
-                            </DialogActions>
-                        </form>
-                    </Dialog>
-                );
-            }
-        }
-    );
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { CollectionUpdateFormDialogData } from '~/store/collections/collection-update-actions';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField } from '~/views-components/form-fields/collection-form-fields';
+
+type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<CollectionUpdateFormDialogData>;
+
+export const DialogCollectionUpdate = (props: DialogCollectionProps) =>
+    <FormDialog
+        dialogTitle='Edit Collection'
+        formFields={CollectionEditFields}
+        submitLabel='Save'
+        {...props}
+    />;
+
+const CollectionEditFields = () => <span>
+    <CollectionNameField />
+    <CollectionDescriptionField />
+</span>;
index 5dde00a62749ae2ef4f431d3b9f113c48bd4039b..49b97a6f9ab1f08f7b3d889db720821a8a43ef18 100644 (file)
@@ -3,99 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { reduxForm, Field } from 'redux-form';
-import { compose } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, WithStyles, withStyles, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress, Button } from '../../../node_modules/@material-ui/core';
-import { TextField } from '~/components/text-field/text-field';
-import { PROJECT_FORM_NAME } from '~/store/project/project-action';
-import { PROJECT_NAME_VALIDATION, PROJECT_DESCRIPTION_VALIDATION } from '~/validators/validators';
-
-type CssRules = 'content' | 'actions' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    content: {
-        display: 'flex',
-        flexDirection: 'column'
-    },
-    actions: {
-        margin: 0,
-        padding: `${theme.spacing.unit}px ${theme.spacing.unit * 3 - theme.spacing.unit / 2}px 
-                ${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px`
-    },
-    buttonWrapper: {
-        position: 'relative'
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: 0,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
-    }
-});
-
-interface DialogProjectDataProps {
-    open: boolean;
-    handleSubmit: any;
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface DialogProjectActionProps {
-    handleClose: () => void;
-    onSubmit: (data: { name: string, description: string }) => void;
-}
-
-type DialogProjectProps = DialogProjectDataProps & DialogProjectActionProps & WithStyles<CssRules>;
-
-export const DialogProjectUpdate = compose(
-    reduxForm({ form: PROJECT_FORM_NAME }),
-    withStyles(styles))(
-
-        class DialogProjectUpdate extends React.Component<DialogProjectProps> {
-            render() {
-                const { handleSubmit, handleClose, onSubmit, open, classes, submitting, invalid, pristine } = this.props;
-                return <Dialog open={open}
-                    onClose={handleClose}
-                    fullWidth={true}
-                    maxWidth='sm'
-                    disableBackdropClick={true}
-                    disableEscapeKeyDown={true}>
-                    <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
-                        <DialogTitle>Edit Collection</DialogTitle>
-                        <DialogContent className={classes.content}>
-                            <Field name='name' 
-                                disabled={submitting}
-                                component={TextField}
-                                validate={PROJECT_NAME_VALIDATION}
-                                label="Project Name" />
-                            <Field name='description' 
-                                disabled={submitting}
-                                component={TextField} 
-                                validate={PROJECT_DESCRIPTION_VALIDATION}
-                                label="Description - optional" />
-                        </DialogContent>
-                        <DialogActions className={classes.actions}>
-                            <Button onClick={handleClose} color="primary"
-                                disabled={submitting}>CANCEL</Button>
-                            <div className={classes.buttonWrapper}>
-                                <Button type="submit" className={classes.saveButton}
-                                    color="primary"
-                                    disabled={invalid || submitting || pristine}
-                                    variant="contained">
-                                    SAVE
-                                </Button>
-                                {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                            </div>
-                        </DialogActions>
-                    </form>
-                </Dialog>;
-            }
-        }
-    );
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { ProjectUpdateFormDialogData } from '~/store/projects/project-update-actions';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { ProjectNameField, ProjectDescriptionField } from '~/views-components/form-fields/project-form-fields';
+
+type DialogProjectProps = WithDialogProps<{}> & InjectedFormProps<ProjectUpdateFormDialogData>;
+
+export const DialogProjectUpdate = (props: DialogProjectProps) =>
+    <FormDialog
+        dialogTitle='Edit Project'
+        formFields={ProjectEditFields}
+        submitLabel='Save'
+        {...props}
+    />;
+
+const ProjectEditFields = () => <span>
+    <ProjectNameField />
+    <ProjectDescriptionField />
+</span>;
diff --git a/src/views-components/dialog-upload/dialog-collection-files-upload.tsx b/src/views-components/dialog-upload/dialog-collection-files-upload.tsx
new file mode 100644 (file)
index 0000000..35c1ed6
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps, Field } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { require } from '~/validators/require';
+import { FileUploaderField } from '~/views-components/file-uploader/file-uploader';
+
+
+type DialogCollectionFilesUploadProps = WithDialogProps<{}> & InjectedFormProps<CollectionCreateFormDialogData>;
+
+export const DialogCollectionFilesUpload = (props: DialogCollectionFilesUploadProps) =>
+    <FormDialog
+        dialogTitle='Upload data'
+        formFields={UploadCollectionFilesFields}
+        submitLabel='Upload data'
+        {...props}
+    />;
+
+const UploadCollectionFilesFields = () =>
+    <Field
+        name='files'
+        validate={FILES_FIELD_VALIDATION}
+        component={FileUploaderField} />;
+
+const FILES_FIELD_VALIDATION = [require];
+
+
diff --git a/src/views-components/file-uploader/file-uploader.tsx b/src/views-components/file-uploader/file-uploader.tsx
new file mode 100644 (file)
index 0000000..d9111f4
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { FileUpload } from '~/components/file-upload/file-upload';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+import { FileUploadProps } from '../../components/file-upload/file-upload';
+import { Dispatch } from 'redux';
+import { fileUploaderActions } from '~/store/file-uploader/file-uploader-actions';
+import { WrappedFieldProps } from 'redux-form';
+import { Typography } from '@material-ui/core';
+
+export type FileUploaderProps = Pick<FileUploadProps, 'disabled' | 'onDrop'>;
+
+const mapStateToProps = (state: RootState, { disabled }: FileUploaderProps): Pick<FileUploadProps, 'files' | 'disabled'> => ({
+    disabled,
+    files: state.fileUploader,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, { onDrop }: FileUploaderProps): Pick<FileUploadProps, 'onDrop'> => ({
+    onDrop: files => {
+        dispatch(fileUploaderActions.SET_UPLOAD_FILES(files));
+        onDrop(files);
+    },
+});
+
+export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpload);
+
+export const FileUploaderField = (props: WrappedFieldProps & { label?: string }) =>
+    <div>
+        <Typography variant='caption'>{props.label}</Typography>
+        <FileUploader disabled={props.meta.submitting} onDrop={props.input.onChange} />
+    </div>;
diff --git a/src/views-components/form-fields/collection-form-fields.tsx b/src/views-components/form-fields/collection-form-fields.tsx
new file mode 100644 (file)
index 0000000..af240fc
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field, WrappedFieldProps } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
+import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+
+export const CollectionNameField = () =>
+    <Field
+        name='name'
+        component={TextField}
+        validate={COLLECTION_NAME_VALIDATION}
+        label="Collection Name" />;
+
+export const CollectionDescriptionField = () =>
+    <Field
+        name='description'
+        component={TextField}
+        validate={COLLECTION_DESCRIPTION_VALIDATION}
+        label="Description - optional" />;
+
+export const CollectionProjectPickerField = () =>
+    <Field
+        name="projectUuid"
+        component={ProjectPicker}
+        validate={COLLECTION_PROJECT_VALIDATION} />;
+
+const ProjectPicker = (props: WrappedFieldProps) =>
+    <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+    </div>;
diff --git a/src/views-components/form-fields/project-form-fields.tsx b/src/views-components/form-fields/project-form-fields.tsx
new file mode 100644 (file)
index 0000000..630877e
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { PROJECT_NAME_VALIDATION, PROJECT_DESCRIPTION_VALIDATION } from "~/validators/validators";
+
+export const ProjectNameField = () =>
+    <Field
+        name='name'
+        component={TextField}
+        validate={PROJECT_NAME_VALIDATION}
+        label="Project Name" />;
+
+export const ProjectDescriptionField = () =>
+    <Field
+        name='description'
+        component={TextField}
+        validate={PROJECT_DESCRIPTION_VALIDATION}
+        label="Description - optional" />;
\ 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 9143c47a2da5efe25b4d983015946706a8487c7f..3859180f0e2d81f0308070d399a1b836d5fa277a 100644 (file)
@@ -6,47 +6,78 @@ import * as React from "react";
 import { Dispatch } from "redux";
 import { connect } from "react-redux";
 import { Typography } from "@material-ui/core";
-import { TreePicker } from "../tree-picker/tree-picker";
-import { TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
+import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
+import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
 import { ProjectResource } from "~/models/project";
 import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
 import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon } from "~/components/icon/icon";
+import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon } from "~/components/icon/icon";
 import { createTreePickerNode } from "~/store/tree-picker/tree-picker";
 import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { FilterBuilder } from "~/common/api/filter-builder";
+import { WrappedFieldProps } from 'redux-form';
 
-type ProjectTreePickerProps = Pick<TreeProps<ProjectResource>, 'toggleItemActive' | 'toggleItemOpen'>;
+type ProjectTreePickerProps = Pick<TreePickerProps, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen'>;
 
-const mapDispatchToProps = (dispatch: Dispatch, props: {onChange: (projectUuid: string) => void}): ProjectTreePickerProps => ({
-    toggleItemActive: id => {
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id }));
-        props.onChange(id);
+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 })));
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId }));
+
+        props.onChange(nodeId);
     },
-    toggleItemOpen: (id, status) => {
-        status === TreeItemStatus.INITIAL
-            ? dispatch<any>(loadProjectTreePickerProjects(id))
-            : dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+    toggleItemOpen: (nodeId, status, pickerId) => {
+        dispatch<any>(toggleItemOpen(nodeId, status, pickerId));
     }
 });
 
+const toggleItemOpen = (nodeId: string, status: TreeItemStatus, pickerId: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (status === TreeItemStatus.INITIAL) {
+            if (pickerId === TreePickerId.PROJECTS) {
+                dispatch<any>(loadProjectTreePickerProjects(nodeId));
+            } else if (pickerId === TreePickerId.FAVORITES) {
+                dispatch<any>(loadFavoriteTreePickerProjects(nodeId === services.authService.getUuid() ? '' : nodeId));
+            } else {
+                // TODO: load sharedWithMe
+            }
+        } else {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
+        }
+    };
+
+const getNotSelectedTreePickerKind = (pickerId: string) => {
+    return [TreePickerId.PROJECTS, TreePickerId.FAVORITES, TreePickerId.SHARED_WITH_ME].filter(nodeId => nodeId !== pickerId);
+};
+
+export enum TreePickerId {
+    PROJECTS = 'Projects',
+    SHARED_WITH_ME = 'Shared with me',
+    FAVORITES = 'Favorites'
+}
+
 export const ProjectTreePicker = connect(undefined, mapDispatchToProps)((props: ProjectTreePickerProps) =>
-    <div style={{display: 'flex', flexDirection: 'column'}}>
-        <Typography variant='caption' style={{flexShrink: 0}}>
+    <div style={{ display: 'flex', flexDirection: 'column' }}>
+        <Typography variant='caption' style={{ flexShrink: 0 }}>
             Select a project
         </Typography>
-        <div style={{flexGrow: 1, overflow: 'auto'}}>
-            <TreePicker {...props} render={renderTreeItem} />
+        <div style={{ flexGrow: 1, overflow: 'auto' }}>
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.PROJECTS} />
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.SHARED_WITH_ME} />
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.FAVORITES} />
         </div>
     </div>);
 
+
 // TODO: move action creator to store directory
-export const loadProjectTreePickerProjects = (id: string) =>
+export const loadProjectTreePickerProjects = (nodeId: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id }));
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.PROJECTS }));
 
-        const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
+        const ownerUuid = nodeId.length === 0 ? services.authService.getUuid() || '' : nodeId;
 
         const filters = new FilterBuilder()
             .addEqual('ownerUuid', ownerUuid)
@@ -54,22 +85,75 @@ export const loadProjectTreePickerProjects = (id: string) =>
 
         const { items } = await services.projectService.list({ filters });
 
-        dispatch<any>(receiveProjectTreePickerData(id, items));
+        dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.PROJECTS));
     };
 
+export const loadFavoriteTreePickerProjects = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const parentId = services.authService.getUuid() || '';
+
+        if (nodeId === '') {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: parentId, pickerId: TreePickerId.FAVORITES }));
+            const { items } = await services.favoriteService.list(parentId);
+
+            dispatch<any>(receiveTreePickerData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
+        } else {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.FAVORITES }));
+            const filters = new FilterBuilder()
+                .addEqual('ownerUuid', nodeId)
+                .getFilters();
+
+            const { items } = await services.projectService.list({ filters });
+
+            dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.FAVORITES));
+        }
+
+    };
+
+const getProjectPickerIcon = (item: TreeItem<ProjectResource>) => {
+    switch (item.data.name) {
+        case TreePickerId.FAVORITES:
+            return FavoriteIcon;
+        case TreePickerId.PROJECTS:
+            return ProjectsIcon;
+        case TreePickerId.SHARED_WITH_ME:
+            return ShareMeIcon;
+        default:
+            return ProjectIcon;
+    }
+};
+
 const renderTreeItem = (item: TreeItem<ProjectResource>) =>
     <ListItemTextIcon
-        icon={ProjectIcon}
+        icon={getProjectPickerIcon(item)}
         name={item.data.name}
         isActive={item.active}
         hasMargin={true} />;
 
+
 // TODO: move action creator to store directory
-const receiveProjectTreePickerData = (id: string, projects: ProjectResource[]) =>
+export const receiveTreePickerData = (nodeId: string, projects: ProjectResource[], pickerId: string) =>
     (dispatch: Dispatch) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
-            id,
-            nodes: projects.map(project => createTreePickerNode({ id: project.uuid, value: project }))
+            nodeId,
+            nodes: projects.map(project => createTreePickerNode({ nodeId: project.uuid, value: project })),
+            pickerId,
         }));
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
     };
+
+export const ProjectTreePickerField = (props: WrappedFieldProps) =>
+    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectTreePicker onChange={handleChange(props)} />
+        {props.meta.dirty && props.meta.error &&
+            <Typography variant='caption' color='error'>
+                {props.meta.error}
+            </Typography>}
+    </div>;
+
+const handleChange = (props: WrappedFieldProps) => (value: string) =>
+    props.input.value === value
+        ? props.input.onChange('')
+        : props.input.onChange(value);
+
index 140119e17e6849ad57d397fa02f362b2f1d7111f..18efdaf88d6d235d66a9547ec9fd6afe18805b9d 100644 (file)
@@ -11,9 +11,9 @@ import { Collapse } from '@material-ui/core';
 import CircularProgress from '@material-ui/core/CircularProgress';
 
 import { ProjectTree } from './project-tree';
-import { TreeItem } from '~/components/tree/tree';
-import { ProjectResource } from '~/models/project';
-import { mockProjectResource } from '~/models/test-utils';
+import { TreeItem, TreeItemStatus } from '../../components/tree/tree';
+import { ProjectResource } from '../../models/project';
+import { mockProjectResource } from '../../models/test-utils';
 
 Enzyme.configure({ adapter: new Adapter() });
 
@@ -25,7 +25,7 @@ describe("ProjectTree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1
+            status: TreeItemStatus.PENDING
         };
         const wrapper = mount(<ProjectTree
             projects={[project]}
@@ -43,14 +43,14 @@ describe("ProjectTree component", () => {
                 id: "3",
                 open: true,
                 active: true,
-                status: 2,
+                status: TreeItemStatus.LOADED,
                 items: [
                     {
                         data: mockProjectResource(),
                         id: "3",
                         open: true,
                         active: true,
-                        status: 1
+                        status: TreeItemStatus.PENDING
                     }
                 ]
             }
@@ -70,7 +70,7 @@ describe("ProjectTree component", () => {
             id: "3",
             open: false,
             active: true,
-            status: 1
+            status: TreeItemStatus.PENDING
         };
         const wrapper = mount(<ProjectTree
             projects={[project]}
index 37028f9db5adc5b4cffe7cfffa25b1d969a7fcee..862227bd888c27c277e4b4d9e28d66a0e36b76f3 100644 (file)
@@ -2,27 +2,37 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
-import { reduxForm, reset, startSubmit, stopSubmit } from "redux-form";
-import { withDialog } from "~/store/dialog/with-dialog";
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { RenameDialog } from "~/components/rename-dialog/rename-dialog";
+import * as React from 'react';
+import { compose } from 'redux';
+import { reduxForm, InjectedFormProps, Field } from 'redux-form';
+import { withDialog, WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { DialogContentText } from '@material-ui/core';
+import { TextField } from '~/components/text-field/text-field';
+import { RENAME_FILE_DIALOG, RenameFileDialogData, renameFile } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 
-export const RENAME_FILE_DIALOG = 'renameFileDialog';
-
-export const openRenameFileDialog = (originalName: string) =>
-    (dispatch: Dispatch) => {
-        dispatch(reset(RENAME_FILE_DIALOG));
-        dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data: originalName }));
-    };
-
-export const [RenameFileDialog] = [RenameDialog]
-    .map(withDialog(RENAME_FILE_DIALOG))
-    .map(reduxForm({
+export const RenameFileDialog = compose(
+    withDialog(RENAME_FILE_DIALOG),
+    reduxForm({
         form: RENAME_FILE_DIALOG,
-        onSubmit: (data, dispatch) => {
-            dispatch(startSubmit(RENAME_FILE_DIALOG));
-            // TODO: call collection file renaming action here
-            setTimeout(() => dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Invalid name' })), 2000);
+        onSubmit: (data: { name: string }, dispatch) => {
+            dispatch<any>(renameFile(data.name));
         }
-    }));
+    })
+)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string }>) =>
+    <FormDialog
+        dialogTitle='Rename'
+        formFields={RenameDialogFormFields}
+        submitLabel='Ok'
+        {...props}
+    />);
+
+const RenameDialogFormFields = (props: WithDialogProps<RenameFileDialogData>) => <>
+    <DialogContentText>
+        {`Please, enter a new name for ${props.data.name}`}
+    </DialogContentText>
+    <Field
+        name='name'
+        component={TextField}
+    />
+</>;
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 09a07443f26b9097ddcccfa9ded1b95d2dea5d85..8b7630ab8cc62609ca9a3898653f1956bd821553 100644 (file)
@@ -3,42 +3,52 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from "react-redux";
-import { Tree, TreeProps, TreeItem } from "~/components/tree/tree";
+import { Tree, TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
 import { RootState } from "~/store/store";
-import { TreePicker as TTreePicker, TreePickerNode, createTreePickerNode } from "~/store/tree-picker/tree-picker";
-import { getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTreePickerNode, TreePickerNode } from "~/store/tree-picker/tree-picker";
+import { getNodeValue, getNodeChildrenIds, Tree as Ttree, createTree } from "~/models/tree";
+import { Dispatch } from "redux";
 
-const memoizedMapStateToProps = () => {
-    let prevState: TTreePicker;
-    let prevTree: Array<TreeItem<any>>;
+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;
+}
 
-    return (state: RootState): Pick<TreeProps<any>, 'items'> => {
-        if (prevState !== state.treePicker) {
-            prevState = state.treePicker;
-            prevTree = getNodeChildren('')(state.treePicker)
-                .map(treePickerToTreeItems(state.treePicker));
+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 {
-            items: prevTree
-        };
+        return mappedProps;
     };
 };
 
-const mapDispatchToProps = (): Pick<TreeProps<any>, 'onContextMenu'> => ({
-    onContextMenu: () => { return; },
+const mapDispatchToProps = (dispatch: Dispatch, props: TreePickerProps): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive'> => ({
+    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(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
 
-const treePickerToTreeItems = (tree: TTreePicker) =>
+const treePickerToTreeItems = (tree: Ttree<TreePickerNode>) =>
     (id: string): TreeItem<any> => {
-        const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ id: '', value: 'InvalidNode' });
-        const items = getNodeChildren(node.id)(tree)
+        const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ nodeId: '', value: 'InvalidNode' });
+        const items = getNodeChildrenIds(node.nodeId)(tree)
             .map(treePickerToTreeItems(tree));
         return {
             active: node.selected,
             data: node.value,
-            id: node.id,
+            id: node.nodeId,
             items: items.length > 0 ? items : undefined,
             open: !node.collapsed,
             status: node.status
diff --git a/src/views-components/update-collection-dialog/update-collection-dialog..tsx b/src/views-components/update-collection-dialog/update-collection-dialog..tsx
deleted file mode 100644 (file)
index 239df58..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { connect } from "react-redux";
-import { Dispatch } from "redux";
-import { SubmissionError } from "redux-form";
-import { RootState } from "~/store/store";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
-import { collectionUpdaterActions, updateCollection } from "~/store/collections/updater/collection-updater-action";
-import { dataExplorerActions } from "~/store/data-explorer/data-explorer-action";
-import { PROJECT_PANEL_ID } from "~/views/project-panel/project-panel";
-import { DialogCollectionUpdate } from "../dialog-update/dialog-collection-update";
-
-const mapStateToProps = (state: RootState) => ({
-    open: state.collections.updater.opened
-});
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
-    handleClose: () => {
-        dispatch(collectionUpdaterActions.CLOSE_COLLECTION_UPDATER());
-    },
-    onSubmit: (data: { name: string, description: string }) => {
-        return dispatch<any>(editCollection(data))
-            .catch((e: any) => {
-                if(e.errors) {
-                    throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
-                }
-            });
-    }
-});
-
-const editCollection = (data: { name: string, description: string }) =>
-    (dispatch: Dispatch) => {
-        return dispatch<any>(updateCollection(data)).then(() => {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: "Collection has been successfully updated.",
-                hideDuration: 2000
-            }));
-            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-        });
-    };
-
-export const UpdateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionUpdate);
diff --git a/src/views-components/update-project-dialog/update-project-dialog.tsx b/src/views-components/update-project-dialog/update-project-dialog.tsx
deleted file mode 100644 (file)
index 0ea23c8..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { connect } from "react-redux";
-import { Dispatch } from "redux";
-import { SubmissionError } from "redux-form";
-import { RootState } from "~/store/store";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
-import { DialogProjectUpdate } from "../dialog-update/dialog-project-update";
-import { projectActions, updateProject } from "~/store/project/project-action";
-
-const mapStateToProps = (state: RootState) => ({
-    open: state.projects.updater.opened
-});
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
-    handleClose: () => {
-        dispatch(projectActions.CLOSE_PROJECT_UPDATER());
-    },
-    onSubmit: (data: { name: string, description: string }) => {
-        return dispatch<any>(editProject(data))
-            .catch((e: any) => {
-                if (e.errors) {
-                    throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Project with this name already exists." : "" });
-                }
-            });
-    }
-});
-
-const editProject = (data: { name: string, description: string }) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const { uuid } = getState().projects.updater;
-        return dispatch<any>(updateProject(data)).then(() => {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: "Project has been successfully updated.",
-                hideDuration: 2000
-            }));
-        });
-    };
-
-export const UpdateProjectDialog = connect(mapStateToProps, mapDispatchToProps)(DialogProjectUpdate);
index 9e32700d034e8eeb5d87bb8c4db65006ae279730..8a0e2f8108332c5110218b1711d5d4ac36242767 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import {
     StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Chip
+    CardHeader, IconButton, CardContent, Grid, Chip, Tooltip
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
@@ -19,8 +19,12 @@ import * as CopyToClipboard from 'react-copy-to-clipboard';
 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' | 'value';
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'label' | 'value';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -40,8 +44,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.palette.grey["500"],
         cursor: 'pointer'
     },
+    label: {
+        fontSize: '0.875rem'
+    },
     value: {
-        textTransform: 'none'
+        textTransform: 'none',
+        fontSize: '0.875rem'
     }
 });
 
@@ -50,85 +58,100 @@ 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 classValue={classes.value}
-                                            label='Collection UUID'
-                                            value={item && item.uuid}>
-                                        <CopyToClipboard text={item && item.uuid}>
-                                            <CopyIcon className={classes.copyIcon} />
-                                        </CopyToClipboard>
+                    <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 label='Number of files' value='14' />
-                                    <DetailsAttribute label='Content size' value='54 MB' />
-                                    <DetailsAttribute classValue={classes.value} label='Owner' value={item && item.ownerUuid} />
-                                    </Grid>
+                                    <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) => () => {
                 this.props.dispatch<any>(deleteCollectionTag(uuid));
             }
 
-            componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
-                if (!item || match.params.id !== item.uuid) {
-                    onItemRouteChange(match.params.id);
-                }
+            onCopy = () => {
+                this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Uuid has been copied",
+                    hideDuration: 2000
+                }));
             }
         }
     )
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 d2e2331..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 FavoritePanelItem {
-    uuid: string;
-    name: string;
-    kind: string;
-    url: string;
-    owner: string;
-    lastModified: string;
-    fileSize?: number;
-    status?: string;
-    isTrashed?: boolean;
-}
-
-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,
-        isTrashed: r.kind === ResourceKind.GROUP || r.kind === ResourceKind.COLLECTION ? r.isTrashed: undefined
-    };
-}
index 49f1f4ab3a593bc96fb2e10bdddeca294dc49c03..62b037e3a56da989baa16aa1c91679c2d47a7a77 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 { ProcessState } from '~/models/process';
 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 | ProcessState;
 }
 
-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: ProcessState.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.']} />;
             }
         }
     )
diff --git a/src/views/process-panel/process-information-card.tsx b/src/views/process-panel/process-information-card.tsx
new file mode 100644 (file)
index 0000000..53c56ca
--- /dev/null
@@ -0,0 +1,121 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    StyleRulesCallback, WithStyles, withStyles, Card,
+    CardHeader, IconButton, CardContent, Grid, Chip, Typography, Tooltip
+} from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { MoreOptionsIcon, ProcessIcon } from '~/components/icon/icon';
+import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
+import { Process } from '~/store/processes/process';
+import { getProcessStatus } from '../../store/processes/process';
+
+type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'headerText' | 'link' | 'content' | 'title' | 'avatar';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        marginBottom: theme.spacing.unit * 2
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start'
+    },
+    label: {
+        display: 'flex',
+        justifyContent: 'flex-end',
+        fontSize: '0.875rem',
+        marginRight: theme.spacing.unit * 3,
+        paddingRight: theme.spacing.unit
+    },
+    value: {
+        textTransform: 'none',
+        fontSize: '0.875rem',
+    },
+    link: {
+        fontSize: '0.875rem',
+        color: theme.palette.primary.main,
+        '&:hover': {
+            cursor: 'pointer'
+        }
+    },
+    chip: {
+        height: theme.spacing.unit * 3,
+        width: theme.spacing.unit * 12,
+        backgroundColor: theme.customs.colors.green700,
+        color: theme.palette.common.white,
+        fontSize: '0.875rem',
+        borderRadius: theme.spacing.unit * 0.625,
+    },
+    headerText: {
+        fontSize: '0.875rem',
+        marginLeft: theme.spacing.unit * 3,
+    },
+    content: {
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 2,
+            paddingTop: '0px'
+        }
+    },
+    title: {
+        overflow: 'hidden'
+    }
+});
+
+export interface ProcessInformationCardDataProps {
+    process: Process;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+}
+
+type ProcessInformationCardProps = ProcessInformationCardDataProps & WithStyles<CssRules>;
+
+export const ProcessInformationCard = withStyles(styles)(
+    ({ classes, process, onContextMenu }: ProcessInformationCardProps) =>
+        <Card className={classes.card}>
+            <CardHeader
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar
+                }}
+                avatar={<ProcessIcon className={classes.iconHeader} />}
+                action={
+                    <div>
+                        <Chip label={getProcessStatus(process)} className={classes.chip} />
+                        <IconButton
+                            aria-label="More options"
+                            onClick={event => onContextMenu(event)}>
+                            <MoreOptionsIcon />
+                        </IconButton>
+                    </div>
+                }
+                title={
+                    <Tooltip title={process.containerRequest.name}>
+                        <Typography noWrap variant="title">
+                           {process.containerRequest.name}
+                        </Typography>
+                    </Tooltip>
+                }
+                subheader={process.containerRequest.description} />
+            <CardContent className={classes.content}>
+                <Grid container>
+                    <Grid item xs={6}>
+                        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                            label='From' value={process.container ? process.container.startedAt : 'N/A'} />
+                        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                            label='To' value={process.container ? process.container.finishedAt : 'N/A'} />
+                        <DetailsAttribute classLabel={classes.label} classValue={classes.link}
+                            label='Workflow' value='???' />
+                    </Grid>
+                    <Grid item xs={6}>
+                        <DetailsAttribute classLabel={classes.link} label='Outputs' />
+                        <DetailsAttribute classLabel={classes.link} label='Inputs' />
+                    </Grid>
+                </Grid>
+            </CardContent>
+        </Card>
+);
\ No newline at end of file
diff --git a/src/views/process-panel/process-panel-root.tsx b/src/views/process-panel/process-panel-root.tsx
new file mode 100644 (file)
index 0000000..feada3a
--- /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 { Grid } from '@material-ui/core';
+import { ProcessInformationCard } from './process-information-card';
+import { DefaultView } from '~/components/default-view/default-view';
+import { ProcessIcon } from '~/components/icon/icon';
+import { Process } from '~/store/processes/process';
+import { SubprocessesCard } from './subprocesses-card';
+
+export interface ProcessPanelRootDataProps {
+    process?: Process;
+}
+
+export interface ProcessPanelRootActionProps {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+}
+
+export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps;
+
+export const ProcessPanelRoot = (props: ProcessPanelRootProps) =>
+    props.process
+        ? <Grid container spacing={16}>
+            <Grid item xs={7}>
+                <ProcessInformationCard
+                    process={props.process}
+                    onContextMenu={props.onContextMenu} />
+            </Grid>
+            <Grid item xs={5}>
+                <SubprocessesCard
+                    subprocesses={4}
+                    filters={[
+                        {
+                            key: 'queued',
+                            value: 1,
+                            label: 'Queued',
+                            checked: true
+                        }, {
+                            key: 'active',
+                            value: 2,
+                            label: 'Active',
+                            checked: true
+                        },
+                        {
+                            key: 'completed',
+                            value: 2,
+                            label: 'Completed',
+                            checked: true
+                        },
+                        {
+                            key: 'failed',
+                            value: 2,
+                            label: 'Failed',
+                            checked: true
+                        }
+                    ]}
+                    onToggle={() => { return; }}
+                />
+            </Grid>
+        </Grid>
+        : <Grid container
+            alignItems='center'
+            justify='center'>
+            <DefaultView
+                icon={ProcessIcon}
+                messages={['Process not found']} />
+        </Grid>;
diff --git a/src/views/process-panel/process-panel.tsx b/src/views/process-panel/process-panel.tsx
new file mode 100644 (file)
index 0000000..421945f
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { RootState } from '~/store/store';
+import { connect } from 'react-redux';
+import { getProcess } from '~/store/processes/process';
+import { Dispatch } from 'redux';
+import { openProcessContextMenu } from '~/store/context-menu/context-menu-actions';
+import { matchProcessRoute } from '~/routes/routes';
+import { ProcessPanelRootDataProps, ProcessPanelRootActionProps, ProcessPanelRoot } from './process-panel-root';
+
+const mapStateToProps = ({ router, resources }: RootState): ProcessPanelRootDataProps => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchProcessRoute(pathname);
+    const uuid = match ? match.params.id : '';
+    return {
+        process: getProcess(uuid)(resources)
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps => ({
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => {
+        dispatch<any>(openProcessContextMenu(event));
+    }
+});
+
+export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
diff --git a/src/views/process-panel/subprocesses-card.tsx b/src/views/process-panel/subprocesses-card.tsx
new file mode 100644 (file)
index 0000000..ac60c9f
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { StyleRulesCallback, withStyles, WithStyles, Card, CardHeader, CardContent, Grid, Switch } from '@material-ui/core';
+import { SubprocessFilter } from '~/components/subprocess-filter/subprocess-filter';
+import { SubprocessFilterDataProps } from '~/components/subprocess-filter/subprocess-filter';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem'
+    }
+});
+
+interface SubprocessesDataProps {
+    subprocesses: number;
+    filters: SubprocessFilterDataProps[];
+    onToggle: (filter: SubprocessFilterDataProps) => void;
+}
+
+type SubprocessesProps = SubprocessesDataProps & WithStyles<CssRules>;
+
+export const SubprocessesCard = withStyles(styles)(
+    ({ classes, filters, subprocesses, onToggle }: SubprocessesProps) => 
+        <Card className={classes.root}>
+            <CardHeader title="Subprocess and filters" />
+            <CardContent>
+                <Grid container direction="column" spacing={16}>
+                    <Grid item xs={12} container spacing={16}>
+                        <SubprocessFilter label='Subprocesses' value={subprocesses} />     
+                    </Grid>
+                    <Grid item xs={12} container spacing={16}>
+                        {
+                            filters.map(filter => 
+                                <SubprocessFilter {...filter} key={filter.key} onToggle={() => onToggle(filter)} />                                                     
+                            )
+                        }
+                    </Grid>
+                </Grid>
+            </CardContent>
+        </Card>
+);
\ 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 ecc5a7d..0000000
+++ /dev/null
@@ -1,33 +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;
-    isTrashed?: boolean;
-}
-
-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,
-        isTrashed: r.kind === ResourceKind.GROUP || r.kind === ResourceKind.COLLECTION ? r.isTrashed: undefined
-    };
-}
index f63584b7866d40b9ce766f9eb01bb2530e55ae98..37a6d202214a6fb1b80114ad4805fac08aace93f 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 | ProcessState;
 }
 
-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: ProcessState.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 8028f2c30ffc10cd8c883e2676aedd5a9175531f..ea3a278bf9090cbe73b29a2a0b64f4b357d6e399 100644 (file)
@@ -4,58 +4,47 @@
 
 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 { collectionCreateActions } from '~/store/collections/creator/collection-creator-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 { CreateProjectDialog } from "~/views-components/create-project-dialog/create-project-dialog";
-
-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 { 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 { CreateCollectionDialog } from '~/views-components/create-collection-dialog/create-collection-dialog';
 import { CollectionPanel } from '../collection-panel/collection-panel';
-import { loadCollection, loadCollectionTags } from '~/store/collection-panel/collection-panel-action';
-import { getCollectionUrl } from '~/models/collection';
-import { UpdateCollectionDialog } from '~/views-components/update-collection-dialog/update-collection-dialog.';
-import { UpdateProjectDialog } from '~/views-components/update-project-dialog/update-project-dialog';
 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 { DialogCollectionCreateWithSelectedFile } from '~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected';
-import { COLLECTION_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-collection-create';
-import { PROJECT_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-project-create';
+import { Routes } from '~/routes/routes';
+import { SidePanel } from '~/views-components/side-panel/side-panel';
+import { ProcessPanel } from '~/views/process-panel/process-panel';
+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 { FilesUploadCollectionDialog } from '~/views-components/dialog-forms/files-upload-collection-dialog';
+import { PartialCopyCollectionDialog } from '~/views-components/dialog-forms/partial-copy-collection-dialog';
+
 import { TrashPanel } from "~/views/trash-panel/trash-panel";
 import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
 
-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: {
@@ -72,12 +61,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",
@@ -91,15 +74,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 {
@@ -112,10 +91,6 @@ interface WorkbenchActionProps {
 
 type WorkbenchProps = WorkbenchDataProps & WorkbenchGeneralProps & WorkbenchActionProps & DispatchProp<any> & WithStyles<CssRules>;
 
-interface NavBreadcrumb extends Breadcrumb {
-    itemId: string;
-}
-
 interface NavMenuItem extends MainAppBarMenuItem {
     action: () => void;
 }
@@ -134,16 +109,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: "",
@@ -179,65 +150,27 @@ 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,
-                                            ownerUuid: item.data.ownerUuid || this.props.authService.getUuid(),
-                                            isTrashed: item.data.isTrashed,
-                                            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={Routes.PROJECTS} component={ProjectPanel} />
+                                    <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
+                                    <Route path={Routes.FAVORITES} component={FavoritePanel} />
+                                    <Route path={Routes.PROCESSES} component={ProcessPanel} />
                                     <Route path="/trash" render={this.renderTrashPanel} />
-                                    <Route path="/collections/:id" render={this.renderCollectionPanel} />
                                 </Switch>
                             </div>
                             {user && <DetailsPanel />}
@@ -247,11 +180,16 @@ export const Workbench = withStyles(styles)(
                         <CreateProjectDialog />
                         <CreateCollectionDialog />
                         <RenameFileDialog />
-                        <DialogCollectionCreateWithSelectedFile />
+                        <PartialCopyCollectionDialog />
+                        <FileRemoveDialog />
+                        <CopyCollectionDialog />
                         <FileRemoveDialog />
                         <MultipleFilesRemoveDialog />
                         <UpdateCollectionDialog />
+                        <FilesUploadCollectionDialog />
                         <UpdateProjectDialog />
+                        <MoveCollectionDialog />
+                        <MoveProjectDialog />
                         <CurrentTokenDialog
                             currentToken={this.props.currentToken}
                             open={this.state.isCurrentTokenDialogOpen}
@@ -260,124 +198,7 @@ export const Workbench = withStyles(styles)(
                 );
             }
 
-            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,
-                        isTrashed: item.isTrashed,
-                        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,
-                        isTrashed: item.isTrashed,
-                        ownerUuid: item.owner || this.props.authService.getUuid(),
-                        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,
-                        isTrashed: item.isTrashed,
-                        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} />
-
-            renderTrashPanel = (props: RouteComponentProps<{ id: string }>) => <TrashPanel
-                onItemRouteChange={() => this.props.dispatch(trashPanelActions.REQUEST_ITEMS())}
-                onContextMenu={(event, item) => {
-                    const kind = item.kind === ResourceKind.PROJECT ? ContextMenuKind.PROJECT : ContextMenuKind.COLLECTION;
-                    this.openContextMenu(event, {
-                        uuid: item.uuid,
-                        name: item.name,
-                        isTrashed: item.isTrashed,
-                        ownerUuid: item.owner,
-                        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}`));
@@ -386,48 +207,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_DIALOG));
-                this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
-            }
-
-            handleCollectionCreationDialogOpen = (itemUuid: string) => {
-                this.props.dispatch(reset(COLLECTION_CREATE_DIALOG));
-                this.props.dispatch(collectionCreateActions.OPEN_COLLECTION_CREATOR({ ownerUuid: itemUuid }));
-            }
-
-            openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; description?: string; isTrashed?: boolean, ownerUuid?: 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 });
             }
index ae2b1c571627921abf4fc87cee07613423376134..67c12647b4625337eae116a1f6feea7f9322da8e 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     esutils "^2.0.2"
     js-tokens "^3.0.0"
 
-"@babel/runtime@^7.0.0-beta.42":
-  version "7.0.0-beta.54"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0-beta.54.tgz#39ebb42723fe7ca4b3e1b00e967e80138d47cadf"
+"@babel/runtime@7.0.0-beta.42":
+  version "7.0.0-beta.42"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0-beta.42.tgz#352e40c92e0460d3e82f49bd7e79f6cda76f919f"
+  dependencies:
+    core-js "^2.5.3"
+    regenerator-runtime "^0.11.1"
+
+"@babel/runtime@7.0.0-beta.56":
+  version "7.0.0-beta.56"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0-beta.56.tgz#cda612dffd5b1719a7b8e91e3040bd6ae64de8b0"
   dependencies:
-    core-js "^2.5.7"
     regenerator-runtime "^0.12.0"
 
-"@material-ui/core@1.4.2":
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-1.4.2.tgz#8a1282e985d4922a4d2b4f7e287d8a716a2fc108"
+"@material-ui/core@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-1.5.0.tgz#00884bb4139d98786d05a97803d19426d4afa55d"
   dependencies:
-    "@babel/runtime" "^7.0.0-beta.42"
+    "@babel/runtime" "7.0.0-beta.42"
     "@types/jss" "^9.5.3"
     "@types/react-transition-group" "^2.0.8"
     brcast "^3.0.1"
     normalize-scroll-left "^0.1.2"
     popper.js "^1.14.1"
     prop-types "^15.6.0"
-    react-event-listener "^0.6.0"
+    react-event-listener "^0.6.2"
     react-jss "^8.1.0"
     react-transition-group "^2.2.1"
-    recompose "^0.27.0"
+    recompose "^0.28.0"
     warning "^4.0.1"
 
-"@material-ui/icons@2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-2.0.0.tgz#f2c4e80d0cb4bbbd433127781da67d93393535f8"
+"@material-ui/icons@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-2.0.2.tgz#0150c38cda089ef284e9b4a730dfe6e88a0b5de6"
   dependencies:
-    "@babel/runtime" "^7.0.0-beta.42"
-    recompose "^0.27.0"
+    "@babel/runtime" "7.0.0-beta.42"
+    recompose "^0.28.0"
 
 "@types/cheerio@*":
   version "0.22.8"
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.6.tgz#dbe8a666156d556ed018e15a4c65f08937c3f628"
 
-"@types/enzyme-adapter-react-16@1.0.2":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.2.tgz#15ae37c64d6221a6f4b3a4aacc357cf773859de4"
+"@types/enzyme-adapter-react-16@1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.3.tgz#0cf7025b036694ca8d596fe38f24162e7117acf1"
   dependencies:
     "@types/enzyme" "*"
 
-"@types/enzyme@*", "@types/enzyme@3.1.12":
+"@types/enzyme@*":
   version "3.1.12"
   resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.1.12.tgz#293bb07c1ef5932d37add3879e72e0f5bc614f3c"
   dependencies:
     "@types/cheerio" "*"
     "@types/react" "*"
 
+"@types/enzyme@3.1.13":
+  version "3.1.13"
+  resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.1.13.tgz#4bbc5c81fa40c9fc7efee25c4a23cb37119a33ea"
+  dependencies:
+    "@types/cheerio" "*"
+    "@types/react" "*"
+
 "@types/history@*":
   version "4.6.2"
   resolved "https://registry.yarnpkg.com/@types/history/-/history-4.6.2.tgz#12cfaba693ba20f114ed5765467ff25fdf67ddb0"
   version "10.5.2"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707"
 
-"@types/node@10.5.5":
-  version "10.5.5"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.5.tgz#8e84d24e896cd77b0d4f73df274027e3149ec2ba"
+"@types/node@10.7.1":
+  version "10.7.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.7.1.tgz#b704d7c259aa40ee052eec678758a68d07132a2e"
 
-"@types/react-copy-to-clipboard@4.2.5":
-  version "4.2.5"
-  resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.2.5.tgz#bda288b4256288676019b75ca86f1714bbd206d4"
+"@types/react-copy-to-clipboard@4.2.6":
+  version "4.2.6"
+  resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.2.6.tgz#d1374550dec803f17f26ec71b62783c5737bfc02"
   dependencies:
     "@types/react" "*"
 
-"@types/react-dom@16.0.6":
-  version "16.0.6"
-  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.6.tgz#f1a65a4e7be8ed5d123f8b3b9eacc913e35a1a3c"
+"@types/react-dom@16.0.7":
+  version "16.0.7"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.7.tgz#54d0f867a76b90597e8432030d297982f25c20ba"
   dependencies:
     "@types/node" "*"
     "@types/react" "*"
 
-"@types/react-dropzone@4.2.1":
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/@types/react-dropzone/-/react-dropzone-4.2.1.tgz#4a973b63a8a227e263ff4eece053f643220f28fc"
+"@types/react-dropzone@4.2.2":
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/@types/react-dropzone/-/react-dropzone-4.2.2.tgz#af0a2595169700c8ab1114e9096285499beaff40"
   dependencies:
     "@types/react" "*"
 
     "@types/react" "*"
     redux "^3.6.0"
 
-"@types/redux-form@7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@types/redux-form/-/redux-form-7.4.4.tgz#2cf62b8eb1dc1b1df95b6b25c2763db196e5c190"
+"@types/redux-form@7.4.5":
+  version "7.4.5"
+  resolved "https://registry.yarnpkg.com/@types/redux-form/-/redux-form-7.4.5.tgz#fae0fa6cfbc613867093d1e0f6a84db17177305e"
   dependencies:
     "@types/react" "*"
     redux "^3.6.0 || ^4.0.0"
@@ -407,6 +420,14 @@ array-unique@^0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
 
+array.prototype.flat@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.1.tgz#812db8f02cad24d3fab65dd67eabe3b8903494a4"
+  dependencies:
+    define-properties "^1.1.2"
+    es-abstract "^1.10.0"
+    function-bind "^1.1.1"
+
 arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
@@ -471,7 +492,7 @@ atob@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.1.tgz#ae2d5a729477f289d60dd7f96a6314a22dd6c22a"
 
-attr-accept@^1.0.3:
+attr-accept@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52"
   dependencies:
@@ -1849,7 +1870,7 @@ core-js@^1.0.0:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
 
-core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.7:
+core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.3:
   version "2.5.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
 
@@ -2440,39 +2461,42 @@ entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
 
-enzyme-adapter-react-16@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.1.1.tgz#a8f4278b47e082fbca14f5bfb1ee50ee650717b4"
+enzyme-adapter-react-16@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.2.0.tgz#c6e80f334e0a817873262d7d01ee9e4747e3c97e"
   dependencies:
-    enzyme-adapter-utils "^1.3.0"
-    lodash "^4.17.4"
-    object.assign "^4.0.4"
+    enzyme-adapter-utils "^1.5.0"
+    function.prototype.name "^1.1.0"
+    object.assign "^4.1.0"
     object.values "^1.0.4"
-    prop-types "^15.6.0"
+    prop-types "^15.6.2"
+    react-is "^16.4.2"
     react-reconciler "^0.7.0"
     react-test-renderer "^16.0.0-0"
 
-enzyme-adapter-utils@^1.3.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.4.0.tgz#c403b81e8eb9953658569e539780964bdc98de62"
+enzyme-adapter-utils@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.5.0.tgz#a020ab3ae79bb1c85e1d51f48f35e995e0eed810"
   dependencies:
+    function.prototype.name "^1.1.0"
     object.assign "^4.1.0"
-    prop-types "^15.6.0"
+    prop-types "^15.6.2"
 
-enzyme@3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.3.0.tgz#0971abd167f2d4bf3f5bd508229e1c4b6dc50479"
+enzyme@3.4.4:
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.4.4.tgz#92c7c6b9e59d4ef0c3d36a75dccc0e41a5c14d21"
   dependencies:
+    array.prototype.flat "^1.2.1"
     cheerio "^1.0.0-rc.2"
-    function.prototype.name "^1.0.3"
-    has "^1.0.1"
+    function.prototype.name "^1.1.0"
+    has "^1.0.3"
     is-boolean-object "^1.0.0"
-    is-callable "^1.1.3"
+    is-callable "^1.1.4"
     is-number-object "^1.0.3"
     is-string "^1.0.4"
     is-subset "^0.1.1"
     lodash "^4.17.4"
-    object-inspect "^1.5.0"
+    object-inspect "^1.6.0"
     object-is "^1.0.1"
     object.assign "^4.1.0"
     object.entries "^1.0.4"
@@ -2492,7 +2516,7 @@ error-ex@^1.2.0:
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.5.1, es-abstract@^1.6.1, es-abstract@^1.7.0:
+es-abstract@^1.10.0, es-abstract@^1.5.1, es-abstract@^1.6.1, es-abstract@^1.7.0:
   version "1.12.0"
   resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.12.0.tgz#9dbbdd27c6856f0001421ca18782d786bf8a6165"
   dependencies:
@@ -3082,7 +3106,7 @@ function-bind@^1.1.0, function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
 
-function.prototype.name@^1.0.3:
+function.prototype.name@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.0.tgz#8bd763cc0af860a859cc5d49384d74b932cd2327"
   dependencies:
@@ -3319,7 +3343,7 @@ has-values@^1.0.0:
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-has@^1.0.1:
+has@^1.0.1, has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
   dependencies:
@@ -3689,7 +3713,7 @@ is-builtin-module@^1.0.0:
   dependencies:
     builtin-modules "^1.0.0"
 
-is-callable@^1.1.1, is-callable@^1.1.3:
+is-callable@^1.1.1, is-callable@^1.1.3, is-callable@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"
 
@@ -5230,7 +5254,7 @@ object-copy@^0.1.0:
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.5.0:
+object-inspect@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.6.0.tgz#c70b6cbf72f274aab4c34c0c82f5167bf82cf15b"
 
@@ -5248,7 +5272,7 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.0.4, object.assign@^4.1.0:
+object.assign@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
   dependencies:
@@ -6138,22 +6162,22 @@ react-dom@16.4.2:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
-react-dropzone@4.2.13:
-  version "4.2.13"
-  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.2.13.tgz#31393c079b4e5ddcc176c095cebc3545d1248b9d"
+react-dropzone@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-5.0.1.tgz#3ed201215794c0f650c6f25a8311a9d96d35ebb6"
   dependencies:
-    attr-accept "^1.0.3"
+    attr-accept "^1.1.3"
     prop-types "^15.5.7"
 
 react-error-overlay@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4"
 
-react-event-listener@^0.6.0:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.1.tgz#41c7a80a66b398c27dd511e22712b02f3d4eccca"
+react-event-listener@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.2.tgz#df405e9578be052b77a76e4c3914686637caecff"
   dependencies:
-    "@babel/runtime" "^7.0.0-beta.42"
+    "@babel/runtime" "7.0.0-beta.42"
     prop-types "^15.6.0"
     warning "^4.0.1"
 
@@ -6161,6 +6185,10 @@ react-is@^16.4.1:
   version "16.4.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.1.tgz#d624c4650d2c65dbd52c72622bbf389435d9776e"
 
+react-is@^16.4.2:
+  version "16.4.2"
+  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.2.tgz#84891b56c2b6d9efdee577cc83501dfc5ecead88"
+
 react-jss@^8.1.0:
   version "8.6.1"
   resolved "https://registry.yarnpkg.com/react-jss/-/react-jss-8.6.1.tgz#a06e2e1d2c4d91b4d11befda865e6c07fbd75252"
@@ -6363,11 +6391,11 @@ realpath-native@^1.0.0:
   dependencies:
     util.promisify "^1.0.0"
 
-recompose@^0.27.0:
-  version "0.27.1"
-  resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba"
+recompose@^0.28.0:
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.28.2.tgz#19e679227bdf979e0d31b73ffe7ae38c9194f4a7"
   dependencies:
-    babel-runtime "^6.26.0"
+    "@babel/runtime" "7.0.0-beta.56"
     change-emitter "^0.1.2"
     fbjs "^0.8.1"
     hoist-non-react-statics "^2.3.1"
@@ -6453,7 +6481,7 @@ regenerate@^1.2.1:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
 
-regenerator-runtime@^0.11.0:
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"