Merge branch '15256-removing-files-during-upload'
authorPeter Amstutz <peter.amstutz@curii.com>
Thu, 12 Dec 2019 15:57:44 +0000 (10:57 -0500)
committerPeter Amstutz <peter.amstutz@curii.com>
Thu, 12 Dec 2019 15:57:44 +0000 (10:57 -0500)
refs #15256

Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

191 files changed:
.gitignore
.licenseignore
README.md
package.json
public/vocabulary-example.json
src/common/config.ts
src/common/formatters.ts
src/common/getuser.ts [new file with mode: 0644]
src/common/regexp.ts [new file with mode: 0644]
src/common/url.ts
src/components/autocomplete/autocomplete.tsx
src/components/breadcrumbs/breadcrumbs.tsx
src/components/file-tree/file-tree-item.tsx
src/components/form-dialog/form-dialog.tsx
src/components/icon/icon.tsx
src/components/list-item-text-icon/list-item-text-icon.tsx
src/components/search-input/search-input.tsx
src/components/select-field/select-field.tsx
src/components/warning/warning.tsx [new file with mode: 0644]
src/index.tsx
src/lib/cwl-svg/plugins/arrange/arrange.ts
src/models/collection.ts
src/models/group.ts
src/models/mount-types.ts
src/models/node.ts
src/models/process.ts
src/models/resource.ts
src/models/runtime-constraints.ts
src/models/scheduling-parameters.ts
src/models/search-bar.ts
src/models/session.ts
src/models/tag.ts
src/models/test-utils.ts
src/models/tree.ts
src/models/user.ts
src/models/vocabulary.test.ts [new file with mode: 0644]
src/models/vocabulary.ts
src/models/workflow.ts
src/routes/routes.ts
src/services/api/filter-builder.test.ts
src/services/api/filter-builder.ts
src/services/api/url-builder.test.ts
src/services/auth-service/auth-service.ts
src/services/collection-files-service/collection-files-service.ts
src/services/collection-service/collection-service-files-response.ts
src/services/collection-service/collection-service.ts
src/services/common-service/common-resource-service.test.ts
src/services/common-service/common-resource-service.ts
src/services/common-service/common-service.ts
src/services/common-service/trashable-resource-service.ts
src/services/container-request-service/container-request-service.ts
src/services/groups-service/groups-service.ts
src/services/link-account-service/link-account-service.ts
src/services/project-service/project-service.test.ts
src/services/project-service/project-service.ts
src/services/search-service/search-service.ts
src/services/services.ts
src/services/user-service/user-service.ts
src/store/advanced-tab/advanced-tab.tsx
src/store/auth/auth-action-session.ts
src/store/auth/auth-action-ssh.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/store/auth/auth-middleware.ts [new file with mode: 0644]
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-create-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-update-actions.ts
src/store/config/config-action.ts [deleted file]
src/store/config/config-reducer.ts [deleted file]
src/store/details-panel/details-panel-action.ts
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/favorites/favorites-actions.ts
src/store/group-details-panel/group-details-panel-actions.ts
src/store/groups-panel/groups-panel-actions.ts
src/store/groups-panel/groups-panel-middleware-service.ts
src/store/link-account-panel/link-account-panel-actions.ts
src/store/link-account-panel/link-account-panel-reducer.test.ts
src/store/my-account/my-account-panel-actions.ts
src/store/navigation/navigation-action.ts
src/store/processes/process-input-actions.ts
src/store/processes/process-move-actions.ts
src/store/processes/process-update-actions.ts
src/store/processes/processes-actions.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/project-tree-picker/project-tree-picker-actions.ts
src/store/projects/project-create-actions.ts
src/store/projects/project-move-actions.ts
src/store/projects/project-update-actions.ts
src/store/public-favorites-panel/public-favorites-middleware-service.ts
src/store/public-favorites/public-favorites-actions.ts
src/store/repositories/repositories-actions.ts
src/store/resources-data/resources-data-actions.ts [deleted file]
src/store/resources-data/resources-data-reducer.ts [deleted file]
src/store/resources-data/resources-data.ts [deleted file]
src/store/rich-text-editor-dialog/rich-text-editor-dialog-actions.tsx
src/store/run-process-panel/run-process-panel-actions.ts
src/store/search-bar/search-bar-actions.ts
src/store/search-bar/search-bar-reducer.ts
src/store/search-bar/search-bar-tree-actions.ts
src/store/search-results-panel/search-results-middleware-service.ts
src/store/search-results-panel/search-results-panel-actions.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/store.ts
src/store/trash-panel/trash-panel-middleware-service.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/users/users-actions.ts
src/store/vocabulary/vocabulary-actions.ts
src/store/vocabulary/vocabulary-selectors.ts [moved from src/store/vocabulary/vocabulary-selctors.ts with 95% similarity]
src/store/workbench/workbench-actions.ts
src/validators/is-float.tsx
src/validators/max-length.tsx
src/validators/require.tsx
src/validators/valid-name.tsx [new file with mode: 0644]
src/validators/validators.tsx
src/views-components/add-session/add-session.tsx [new file with mode: 0644]
src/views-components/api-client-authorizations-dialog/help-dialog.tsx
src/views-components/api-token/api-token.tsx
src/views-components/compute-nodes-dialog/attributes-dialog.tsx
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/process-action-set.ts
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/collection-details.tsx
src/views-components/details-panel/details-data.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/dialog-copy/dialog-copy.tsx
src/views-components/dialog-forms/create-collection-dialog.ts
src/views-components/dialog-move/dialog-move-to.tsx
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/form-fields/search-bar-form-fields.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-app-bar/admin-menu.tsx
src/views-components/main-app-bar/anonymous-menu.tsx
src/views-components/main-app-bar/main-app-bar.tsx
src/views-components/process-input-dialog/process-input-dialog.tsx
src/views-components/project-properties-dialog/project-properties-dialog.tsx
src/views-components/project-tree-picker/project-tree-picker.tsx [deleted file]
src/views-components/project-tree/project-tree.test.tsx [deleted file]
src/views-components/project-tree/project-tree.tsx [deleted file]
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/projects-tree-picker/tree-picker-field.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-chip.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-field-common.tsx
src/views-components/resource-properties-form/property-key-field.tsx
src/views-components/resource-properties-form/property-value-field.tsx
src/views-components/resource-properties-form/resource-properties-form.tsx
src/views-components/search-bar/search-bar-advanced-properties-view.tsx
src/views-components/search-bar/search-bar-advanced-view.tsx
src/views-components/search-bar/search-bar-basic-view.tsx
src/views-components/search-bar/search-bar-save-queries.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views-components/search-bar/search-bar.tsx
src/views-components/sharing-dialog/permission-select.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views-components/side-panel/side-panel.tsx
src/views/collection-panel/collection-panel.tsx
src/views/inactive-panel/inactive-panel.tsx
src/views/keep-service-panel/keep-service-panel-root.tsx
src/views/link-account-panel/link-account-panel-root.tsx
src/views/link-account-panel/link-account-panel.tsx
src/views/login-panel/login-panel.tsx
src/views/main-panel/main-panel-root.tsx
src/views/main-panel/main-panel.tsx
src/views/run-process-panel/inputs/directory-array-input.tsx
src/views/run-process-panel/inputs/file-array-input.tsx
src/views/run-process-panel/run-process-advanced-form.tsx
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/run-process-panel/run-process-second-step.tsx
src/views/search-results-panel/search-results-panel-view.tsx
src/views/search-results-panel/search-results-panel.tsx
src/views/site-manager-panel/site-manager-panel-root.tsx
src/views/site-manager-panel/site-manager-panel.tsx
src/views/trash-panel/trash-panel.tsx
src/views/user-panel/user-panel.tsx
src/views/workbench/fed-login.tsx
src/views/workbench/workbench.tsx
src/views/workflow-panel/workflow-panel-view.tsx
src/websocket/websocket.ts
tslint.json
yarn.lock

index c18f27f6572824668327ebd997eafd3f0c77d76d..45df030d3294be423b92aa8d9f355b00a7f7a34a 100644 (file)
@@ -22,6 +22,7 @@
 .env.development.local
 .env.test.local
 .env.production.local
+.npm.local
 
 npm-debug.log*
 yarn-debug.log*
index 09914c902bc81a844c2dca12dfc90bdc6266818d..7ac3c8369abb351a31acb5ec6a4d616b4fb9efc1 100644 (file)
@@ -12,3 +12,4 @@ public/*
 .licenseignore
 .yarnrc
 .npmrc
+src/lib/cwl-svg/*
index 425d1787848e5b9dad4e077eed23b2f73aea47ea..e2e14ce47e06f7fc5ddc67b1732419f60d071a07 100644 (file)
--- a/README.md
+++ b/README.md
@@ -46,10 +46,18 @@ Currently this configuration schema is supported:
 }
 ```
 
+#### API_HOST
+
+The Arvados base URL. 
+
+The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable.
+
 #### VOCABULARY_URL
 Local path, or any URL that allows cross-origin requests. See 
 [Vocabulary JSON file example](public/vocabulary-example.json).
 
+To use the URL defined in the Arvados cluster configuration, remove the entire `VOCABULARY_URL` entry from the runtime configuration. Found in `/config.json` by default.
+
 ### FILE_VIEWERS_CONFIG_URL
 Local path, or any URL that allows cross-origin requests. See:
 
@@ -57,6 +65,8 @@ Local path, or any URL that allows cross-origin requests. See:
 
 [File viewers config scheme](src/models/file-viewers-config.ts)
 
+To use the URL defined in the Arvados cluster configuration, remove the entire `FILE_VIEWERS_CONFIG_URL` entry from the runtime configuration. Found in `/config.json` by default.
+
 ### Licensing
 
 Arvados is Free Software. See COPYING for information about Arvados Free
index 68d23bfaaab79b07b71fc077a0df18bb381a1b8c..1059a07d3585904d7eda83d1ca414d66e2e42496 100644 (file)
     "@types/redux-form": "7.4.12",
     "@types/reselect": "2.2.0",
     "@types/shell-quote": "1.6.0",
-    "axios": "0.18.0",
+    "axios": "0.18.1",
     "classnames": "2.2.6",
     "cwlts": "1.15.29",
     "debounce": "1.2.0",
     "file-saver": "2.0.1",
+    "fstream": "1.0.12",
+    "handlebars": "4.0.14",
     "is-image": "2.0.0",
-    "js-yaml": "3.12.0",
+    "js-yaml": "3.13.1",
     "jssha": "2.3.1",
     "jszip": "3.1.5",
-    "lodash": "4.17.11",
+    "lodash": "4.17.13",
+    "lodash-es": "4.17.14",
+    "lodash.mergewith": "4.6.2",
+    "lodash.template": "4.5.0",
+    "mem": "4.0.0",
     "react": "16.5.2",
     "react-copy-to-clipboard": "5.0.1",
     "react-dnd": "5.0.0",
     "redux-form": "7.4.2",
     "redux-thunk": "2.3.0",
     "reselect": "4.0.0",
+    "set-value": "2.0.1",
     "shell-quote": "1.6.1",
+    "sinon": "7.3",
+    "ts-mock-imports": "1.2.6",
+    "tslint-etc": "1.6.0",
     "unionize": "2.1.2",
-    "uuid": "3.3.2"
+    "uuid": "3.3.2",
+    "webpack-dev-server": "3.1.1"
   },
   "scripts": {
     "start": "react-scripts-ts start",
@@ -74,6 +85,7 @@
     "@types/react-router-dom": "4.3.1",
     "@types/react-router-redux": "5.0.16",
     "@types/redux-devtools": "3.0.44",
+    "@types/sinon": "7.5",
     "@types/uuid": "3.4.4",
     "axios-mock-adapter": "1.15.0",
     "enzyme": "3.6.0",
index b227dc23b26c7fdf2dccfa4f25d7f05c557d2541..59d4de7aab3f9a69b1bac6af67225b01c107083e 100644 (file)
 {
-    "strict": false,
+    "strict_tags": false,
     "tags": {
-        "fruit": {
-            "values": ["pineapple", "tomato", "orange", "banana", "advocado", "lemon", "apple", "peach", "strawberry"],
-            "strict": true
+        "IDTAGFRUITS": {
+            "strict": false,
+            "labels": [
+                {"label": "Fruit"}
+            ],
+            "values": {
+                "IDVALFRUITS1": {
+                    "labels": [
+                        {"label": "Pineapple"}
+                    ]
+                },
+                "IDVALFRUITS2": {
+                    "labels": [
+                        {"label": "Tomato"}
+                    ]
+                },
+                "IDVALFRUITS3": {
+                    "labels": [
+                        {"label": "Orange"}
+                    ]
+                },
+                "IDVALFRUITS4": {
+                    "labels": [
+                        {"label": "Banana"}
+                    ]
+                },
+                "IDVALFRUITS5": {
+                    "labels": [
+                        {"label": "Advocado"}
+                    ]
+                },
+                "IDVALFRUITS6": {
+                    "labels": [
+                        {"label": "Lemon"}
+                    ]
+                },
+                "IDVALFRUITS7": {
+                    "labels": [
+                        {"label": "Apple"}
+                    ]
+                },
+                "IDVALFRUITS8": {
+                    "labels": [
+                        {"label": "Peach"}
+                    ]
+                },
+                "IDVALFRUITS9": {
+                    "labels": [
+                        {"label": "Strawberry"}
+                    ]
+                }
+            }
         },
-        "animal": {
-            "values": ["human", "dog", "elephant", "eagle"],
-            "strict": false
+        "IDTAGANIMALS": {
+            "strict": false,
+            "labels": [
+                {"label": "Animal" },
+                {"label": "Creature"}
+            ],
+            "values": {
+                "IDVALANIMALS1": {
+                    "labels": [
+                        {"label": "Human"},
+                        {"label": "Homo sapiens"}
+                    ]
+                },
+                "IDVALANIMALS2": {
+                    "labels": [
+                        {"label": "Dog"},
+                        {"label": "Canis lupus familiaris"}
+                    ]
+                },
+                "IDVALANIMALS3": {
+                    "labels": [
+                        {"label": "Elephant"},
+                        {"label": "Loxodonta"}
+                    ]
+                },
+                "IDVALANIMALS4": {
+                    "labels": [
+                        {"label": "Eagle"},
+                        {"label": "Haliaeetus leucocephalus"}
+                    ]
+                }
+            }
         },
-        "color": {
-            "values": ["yellow", "red", "magenta", "green"],
-            "strict": false
+        "IDTAGCOLORS": {
+            "strict": false,
+            "labels": [
+                {"label": "Color"}
+            ],
+            "values": {
+                "IDVALCOLORS1": {
+                    "labels": [
+                        {"label": "Yellow"}
+                    ]
+                },
+                "IDVALCOLORS2": {
+                    "labels": [
+                        {"label": "Red"}
+                    ]
+                },
+                "IDVALCOLORS3": {
+                    "labels": [
+                        {"label": "Magenta"}
+                    ]
+                },
+                "IDVALCOLORS4": {
+                    "labels": [
+                        {"label": "Green"}
+                    ]
+                }
+            }
         },
-        "text": {},
-        "category": {
-            "values": ["experimental", "development", "production"]
+        "IDTAGCOMMENT": {
+            "labels": [
+                {"label": "Comment"},
+                {"label": "Text"}
+            ]
         },
-        "comments": {},
-        "importance": {
-            "values": ["critical", "important", "low priority"]
+        "IDTAGCATEGORIES": {
+            "strict": true,
+            "labels": [
+                {"label": "Category"}
+            ],
+            "values": {
+                "IDTAGCAT1": {
+                    "labels": [
+                        {"label": "Experimental"}
+                    ]
+                },
+                "IDTAGCAT2": {
+                    "labels": [
+                        {"label": "Development"}
+                    ]
+                },
+                "IDTAGCAT3": {
+                    "labels": [
+                        {"label": "Production"}
+                    ]
+                }
+            }
         },
-        "size": {
-            "values": ["x-small", "small", "medium", "large", "x-large"]
+        "IDTAGIMPORTANCES": {
+            "strict": true,
+            "labels": [
+                {"label": "Importance"},
+                {"label": "Priority"}
+            ],
+            "values": {
+                "IDVALIMPORTANCES1": {
+                    "labels": [
+                        {"label": "Critical"},
+                        {"label": "Urgent"},
+                        {"label": "High"}
+                    ]
+                },
+                "IDVALIMPORTANCES2": {
+                    "labels": [
+                        {"label": "Normal"},
+                        {"label": "Moderate"}
+                    ]
+                },
+                "IDVALIMPORTANCES3": {
+                    "labels": [
+                        {"label": "Low"}
+                    ]
+                }
+            }
         },
-        "country": {
-            "values": ["Afghanistan","Ã…land Islands","Albania","Algeria","American Samoa","AndorrA","Angola","Anguilla","Antarctica","Antigua and Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia and Herzegovina","Botswana","Bouvet Island","Brazil","British Indian Ocean Territory","Brunei Darussalam","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Canada","Cape Verde","Cayman Islands","Central African Republic","Chad","Chile","China","Christmas Island","Cocos (Keeling) Islands","Colombia","Comoros","Congo","Congo, The Democratic Republic of the","Cook Islands","Costa Rica","Cote D'Ivoire","Croatia","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Ethiopia","Falkland Islands (Malvinas)","Faroe Islands","Fiji","Finland","France","French Guiana","French Polynesia","French Southern Territories","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guadeloupe","Guam","Guatemala","Guernsey","Guinea","Guinea-Bissau","Guyana","Haiti","Heard Island and Mcdonald Islands","Holy See (Vatican City State)","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran, Islamic Republic Of","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kiribati","Korea, Democratic People'S Republic of","Korea, Republic of","Kuwait","Kyrgyzstan","Lao People'S Democratic Republic","Latvia","Lebanon","Lesotho","Liberia","Libyan Arab Jamahiriya","Liechtenstein","Lithuania","Luxembourg","Macao","Macedonia, The Former Yugoslav Republic of","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Marshall Islands","Martinique","Mauritania","Mauritius","Mayotte","Mexico","Micronesia, Federated States of","Moldova, Republic of","Monaco","Mongolia","Montserrat","Morocco","Mozambique","Myanmar","Namibia","Nauru","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Niue","Norfolk Island","Northern Mariana Islands","Norway","Oman","Pakistan","Palau","Palestinian Territory, Occupied","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Pitcairn","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russian Federation","RWANDA","Saint Helena","Saint Kitts and Nevis","Saint Lucia","Saint Pierre and Miquelon","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia and Montenegro","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Georgia and the South Sandwich Islands","Spain","Sri Lanka","Sudan","Suriname","Svalbard and Jan Mayen","Swaziland","Sweden","Switzerland","Syrian Arab Republic","Taiwan, Province of China","Tajikistan","Tanzania, United Republic of","Thailand","Timor-Leste","Togo","Tokelau","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Turks and Caicos Islands","Tuvalu","Uganda","Ukraine","United Arab Emirates","United Kingdom","United States","United States Minor Outlying Islands","Uruguay","Uzbekistan","Vanuatu","Venezuela","Viet Nam","Virgin Islands, British","Virgin Islands, U.S.","Wallis and Futuna","Western Sahara","Yemen","Zambia","Zimbabwe"],
-            "strict": true
+        "IDTAGSIZES": {
+            "strict": true,
+            "labels": [
+                {"label": "Size"}
+            ],
+            "values": {
+                "IDVALSIZES1": {
+                    "labels": [
+                        {"label": "XS"},
+                        {"label": "x-small"}
+                    ]
+                },
+                "IDVALSIZES2": {
+                    "labels": [
+                        {"label": "S"},
+                        {"label": "small"}
+                    ]
+                },
+                "IDVALSIZES3": {
+                    "labels": [
+                        {"label": "M"},
+                        {"label": "medium"}
+                    ]
+                },
+                "IDVALSIZES4": {
+                    "labels": [
+                        {"label": "L"},
+                        {"label": "large"}
+                    ]
+                },
+                "IDVALSIZES5": {
+                    "labels": [
+                        {"label": "XL"},
+                        {"label": "x-large"}
+                    ]
+                }
+            }
         }
     }
 }
\ No newline at end of file
index 71b7774c5fa8d765818b7ce611f6e4bfa1805c11..7d974342a704198686b8e20a959e537c0e03a73c 100644 (file)
 
 import Axios from "axios";
 
-export const CONFIG_URL = process.env.REACT_APP_ARVADOS_CONFIG_URL || "/config.json";
+export const WORKBENCH_CONFIG_URL = process.env.REACT_APP_ARVADOS_CONFIG_URL || "/config.json";
 
-export interface Config {
-    auth: {};
-    basePath: string;
+interface WorkbenchConfig {
+    API_HOST: string;
+    VOCABULARY_URL?: string;
+    FILE_VIEWERS_CONFIG_URL?: string;
+}
+
+export interface ClusterConfigJSON {
+    ClusterID: string;
+    RemoteClusters: {
+        [key: string]: {
+            ActivateUsers: boolean
+            Host: string
+            Insecure: boolean
+            Proxy: boolean
+            Scheme: string
+        }
+    };
+    Services: {
+        Controller: {
+            ExternalURL: string
+        }
+        Workbench1: {
+            ExternalURL: string
+        }
+        Workbench2: {
+            ExternalURL: string
+        }
+        Websocket: {
+            ExternalURL: string
+        }
+        WebDAV: {
+            ExternalURL: string
+        }
+    };
+    Workbench: {
+        ArvadosDocsite: string;
+        VocabularyURL: string;
+        FileViewersConfigURL: string;
+        WelcomePageHTML: string;
+        InactivePageHTML: string;
+        SiteName: string;
+    };
+    Login: {
+        LoginCluster: string;
+    };
+}
+
+export class Config {
     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: {
         [key: string]: 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;
-    workbench2Url?: string;
+    workbench2Url: string;
     vocabularyUrl: string;
     fileViewersConfigUrl: string;
+    loginCluster: string;
+    clusterConfig: ClusterConfigJSON;
 }
 
+export const buildConfig = (clusterConfigJSON: ClusterConfigJSON): Config => {
+    const config = new Config();
+    config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL;
+    config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`;
+    config.uuidPrefix = clusterConfigJSON.ClusterID;
+    config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL;
+    config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL;
+    config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL;
+    config.keepWebServiceUrl = clusterConfigJSON.Services.WebDAV.ExternalURL;
+    config.loginCluster = clusterConfigJSON.Login.LoginCluster;
+    config.clusterConfig = clusterConfigJSON;
+    mapRemoteHosts(clusterConfigJSON, config);
+    return config;
+};
+
 export const fetchConfig = () => {
     return Axios
-        .get<ConfigJSON>(CONFIG_URL + "?nocache=" + (new Date()).getTime())
+        .get<WorkbenchConfig>(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime())
         .then(response => response.data)
-        .catch(() => Promise.resolve(getDefaultConfig()))
-        .then(config => Axios
-            .get<Config>(getDiscoveryURL(config.API_HOST))
-            .then(response => ({
-                // TODO: After tests delete `|| '/vocabulary-example.json'`
-                // TODO: After tests delete `|| '/file-viewers-example.json'`
-                config: {
-                    ...response.data,
-                    vocabularyUrl: config.VOCABULARY_URL || '/vocabulary-example.json',
-                    fileViewersConfigUrl: config.FILE_VIEWERS_CONFIG_URL || '/file-viewers-example.json'
-                },
-                apiHost: config.API_HOST,
-            })));
+        .catch(() => {
+            console.warn(`There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.`);
+            return Promise.resolve(getDefaultConfig());
+        })
+        .then(workbenchConfig => {
+            if (workbenchConfig.API_HOST === undefined) {
+                throw new Error(`Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`);
+            }
+            return Axios.get<ClusterConfigJSON>(getClusterConfigURL(workbenchConfig.API_HOST)).then(response => {
+                const clusterConfigJSON = response.data;
+                const config = buildConfig(clusterConfigJSON);
+                const warnLocalConfig = (varName: string) => console.warn(
+                    `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
+remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`);
+
+                // Check if the workbench config has an entry for vocabulary and file viewer URLs
+                // If so, use these values (even if it is an empty string), but print a console warning.
+                // Otherwise, use the cluster config.
+                let fileViewerConfigUrl;
+                if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) {
+                    warnLocalConfig("FILE_VIEWERS_CONFIG_URL");
+                    fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL;
+                }
+                else {
+                    fileViewerConfigUrl = clusterConfigJSON.Workbench.FileViewersConfigURL || "/file-viewers-example.json";
+                }
+                config.fileViewersConfigUrl = fileViewerConfigUrl;
 
+                let vocabularyUrl;
+                if (workbenchConfig.VOCABULARY_URL !== undefined) {
+                    warnLocalConfig("VOCABULARY_URL");
+                    vocabularyUrl = workbenchConfig.VOCABULARY_URL;
+                }
+                else {
+                    vocabularyUrl = clusterConfigJSON.Workbench.VocabularyURL || "/vocabulary-example.json";
+                }
+                config.vocabularyUrl = vocabularyUrl;
+
+                return { config, apiHost: workbenchConfig.API_HOST };
+            });
+        });
+};
+
+// Maps remote cluster hosts and removes the default RemoteCluster entry
+export const mapRemoteHosts = (clusterConfigJSON: ClusterConfigJSON, config: Config) => {
+    config.remoteHosts = {};
+    Object.keys(clusterConfigJSON.RemoteClusters).forEach(k => { config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; });
+    delete config.remoteHosts["*"];
 };
 
+export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): ClusterConfigJSON => ({
+    ClusterID: "",
+    RemoteClusters: {},
+    Services: {
+        Controller: { ExternalURL: "" },
+        Workbench1: { ExternalURL: "" },
+        Workbench2: { ExternalURL: "" },
+        Websocket: { ExternalURL: "" },
+        WebDAV: { ExternalURL: "" },
+    },
+    Workbench: {
+        ArvadosDocsite: "",
+        VocabularyURL: "",
+        FileViewersConfigURL: "",
+        WelcomePageHTML: "",
+        InactivePageHTML: "",
+        SiteName: "",
+    },
+    Login: {
+        LoginCluster: "",
+    },
+    ...config
+});
+
 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: '',
+    baseUrl: "",
+    keepWebServiceUrl: "",
     remoteHosts: {},
-    remoteHostsViaDNS: false,
-    resources: {},
-    revision: '',
-    rootUrl: '',
-    schemas: {},
-    servicePath: '',
-    sourceVersion: '',
-    source_version: '',
-    title: '',
-    uuidPrefix: '',
-    version: '',
-    websocketUrl: '',
-    workbenchUrl: '',
-    vocabularyUrl: '',
-    fileViewersConfigUrl: '',
+    rootUrl: "",
+    uuidPrefix: "",
+    websocketUrl: "",
+    workbenchUrl: "",
+    workbench2Url: "",
+    vocabularyUrl: "",
+    fileViewersConfigUrl: "",
+    loginCluster: "",
+    clusterConfig: mockClusterConfigJSON({}),
     ...config
 });
 
-interface ConfigJSON {
-    API_HOST: string;
-    VOCABULARY_URL: string;
-    FILE_VIEWERS_CONFIG_URL: string;
-}
-
-const getDefaultConfig = (): ConfigJSON => ({
-    API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "",
-    VOCABULARY_URL: "",
-    FILE_VIEWERS_CONFIG_URL: "",
-});
+const getDefaultConfig = (): WorkbenchConfig => {
+    let apiHost = "";
+    const envHost = process.env.REACT_APP_ARVADOS_API_HOST;
+    if (envHost !== undefined) {
+        console.warn(`Using default API host ${envHost}.`);
+        apiHost = envHost;
+    }
+    else {
+        console.warn(`No API host was found in the environment. Workbench may not be able to communicate with Arvados components.`);
+    }
+    return {
+        API_HOST: apiHost,
+        VOCABULARY_URL: undefined,
+        FILE_VIEWERS_CONFIG_URL: undefined,
+    };
+};
 
-export const DISCOVERY_URL = 'discovery/v1/apis/arvados/v1/rest';
-export const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${DISCOVERY_URL}?nocache=${(new Date()).getTime()}`;
+export const ARVADOS_API_PATH = "arvados/v1";
+export const CLUSTER_CONFIG_PATH = "arvados/v1/config";
+export const DISCOVERY_DOC_PATH = "discovery/v1/apis/arvados/v1/rest";
+export const getClusterConfigURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${(new Date()).getTime()}`;
index 377e78e42a8678dae93980549bda9c6a10fc8020..819875bec14c6527ce177ee6478ac435a698844d 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { PropertyValue } from "~/models/search-bar";
+import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "~/models/vocabulary";
 
 export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
     if (isoDate) {
@@ -76,7 +77,10 @@ const FILE_SIZES = [
     }
 ];
 
-export const formatPropertyValue = (pv: PropertyValue) => {
+export const formatPropertyValue = (pv: PropertyValue, vocabulary?: Vocabulary) => {
+    if (vocabulary && pv.keyID && pv.valueID) {
+        return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(pv.keyID, pv.valueID!, vocabulary)}`;
+    }
     if (pv.key) {
         return pv.value
             ? `${pv.key}: ${pv.value}`
diff --git a/src/common/getuser.ts b/src/common/getuser.ts
new file mode 100644 (file)
index 0000000..dc96f92
--- /dev/null
@@ -0,0 +1,10 @@
+import { RootState } from '~/store/store';
+
+export const getUserUuid = (state: RootState) => {
+    const user = state.auth.user;
+    if (user) {
+        return user.uuid;
+    } else {
+        return undefined;
+    }
+};
diff --git a/src/common/regexp.ts b/src/common/regexp.ts
new file mode 100644 (file)
index 0000000..eca24c7
--- /dev/null
@@ -0,0 +1,6 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const escapeRegExp = (st: string) =>
+    st.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
index 1824f26a942426c29f544f9d77decd4ce8b58002..9789b65effb6b47d6ed14c0589cb39b2b032c2a4 100644 (file)
@@ -4,3 +4,12 @@ export function getUrlParameter(search: string, name: string) {
     const results = regex.exec(search);
     return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
 }
+
+export function normalizeURLPath(url: string) {
+    const u = new URL(url);
+    u.pathname = u.pathname.replace(/\/\//, '/');
+    if (u.pathname[u.pathname.length - 1] === '/') {
+        u.pathname = u.pathname.substr(0, u.pathname.length - 1);
+    }
+    return u.toString();
+}
index 4b19b77115b388e3613c6004013ef6501aded2b6..e01673b70e6d992bb608fbf958072510dfd4c654 100644 (file)
@@ -3,7 +3,13 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper as MuiPaper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText } from '@material-ui/core';
+import {
+    Input as MuiInput,
+    Chip as MuiChip,
+    Popper as MuiPopper,
+    Paper as MuiPaper,
+    FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText
+} from '@material-ui/core';
 import { PopperProps } from '@material-ui/core/Popper';
 import { WithStyles } from '@material-ui/core/styles';
 import { noop } from 'lodash';
index 444ac75ef51b97c0f1df5ba8cf55b828bbf571c1..207823307c9284b31fa0a4ca969f974b74a9ea23 100644 (file)
@@ -6,6 +6,7 @@ import * as React from 'react';
 import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } from '@material-ui/core';
 import ChevronRightIcon from '@material-ui/icons/ChevronRight';
 import { withStyles } from '@material-ui/core';
+import { IllegalNamingWarning } from '../warning/warning';
 
 export interface Breadcrumb {
     label: string;
@@ -37,8 +38,10 @@ export const Breadcrumbs = withStyles(styles)(
     {
         items.map((item, index) => {
             const isLastItem = index === items.length - 1;
+            const isFirstItem = index === 0;
             return (
                 <React.Fragment key={index}>
+                    {isFirstItem ? null : <IllegalNamingWarning name={item.label} />}
                     <Tooltip title={item.label}>
                         <Button
                             color="inherit"
index 0ece937775d2527fc1881a198314f224be6840fa..dc8f09b96fb480e94e35877ec86f5d25d5279a47 100644 (file)
@@ -9,7 +9,6 @@ import { Typography, IconButton, StyleRulesCallback, withStyles, WithStyles, Too
 import { formatFileSize } from "~/common/formatters";
 import { ListItemTextIcon } from "../list-item-text-icon/list-item-text-icon";
 import { FileTreeData } from "./file-tree-data";
-import { FileThumbnail } from '~/components/file-tree/file-thumbnail';
 
 type CssRules = "root" | "spacer" | "sizeInfo" | "button" | "moreOptions";
 
index b5654e86675c9072824da70da4f3f37b54c0ebd8..e95693df188309706edf3accbd09809faf760a98 100644 (file)
@@ -48,7 +48,7 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
     <Dialog
         open={props.open}
         onClose={props.closeDialog}
-        disableBackdropClick={props.submitting}
+        disableBackdropClick
         disableEscapeKeyDown={props.submitting}
         fullWidth
         maxWidth='md'>
index 44a2b2334bbfb0b2099796aa1d901d5133b41c90..a3d01e9439bcd10c9a59e0a0313b0d9dc7d2e662 100644 (file)
@@ -21,6 +21,7 @@ import CreateNewFolder from '@material-ui/icons/CreateNewFolder';
 import Delete from '@material-ui/icons/Delete';
 import DeviceHub from '@material-ui/icons/DeviceHub';
 import Edit from '@material-ui/icons/Edit';
+import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded';
 import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
 import Folder from '@material-ui/icons/Folder';
 import GetApp from '@material-ui/icons/GetApp';
@@ -73,6 +74,7 @@ export const DetailsIcon: IconType = (props) => <Info {...props} />;
 export const DownloadIcon: IconType = (props) => <GetApp {...props} />;
 export const EditSavedQueryIcon: IconType = (props) => <Create {...props} />;
 export const ExpandIcon: IconType = (props) => <ExpandMoreIcon {...props} />;
+export const ErrorIcon: IconType = (props) => <ErrorRoundedIcon style={{color: '#ff0000'}} {...props} />;
 export const FavoriteIcon: IconType = (props) => <Star {...props} />;
 export const HelpIcon: IconType = (props) => <Help {...props} />;
 export const HelpOutlineIcon: IconType = (props) => <HelpOutline {...props} />;
index 3afc2cf8773293a18566c468253aaade648c3a4a..375538d56a2f753203b94f55ad4f1633750c4777 100644 (file)
@@ -33,6 +33,7 @@ export interface ListItemTextIconDataProps {
     isActive?: boolean;
     hasMargin?: boolean;
     iconSize?: number;
+    nameDecorator?: JSX.Element;
 }
 
 type ListItemTextIconProps = ListItemTextIconDataProps & WithStyles<CssRules>;
@@ -40,7 +41,7 @@ type ListItemTextIconProps = ListItemTextIconDataProps & WithStyles<CssRules>;
 export const ListItemTextIcon = withStyles(styles)(
     class extends React.Component<ListItemTextIconProps, {}> {
         render() {
-            const { classes, isActive, hasMargin, name, icon: Icon, iconSize } = this.props;
+            const { classes, isActive, hasMargin, name, icon: Icon, iconSize, nameDecorator } = this.props;
             return (
                 <Typography component='span' className={classes.root}>
                     <ListItemIcon className={classnames({
@@ -50,8 +51,9 @@ export const ListItemTextIcon = withStyles(styles)(
 
                         <Icon style={{ fontSize: `${iconSize}rem` }} />
                     </ListItemIcon>
+                    {nameDecorator || null}
                     <ListItemText primary={
-                        <Typography  className={classnames(classes.listItemText, {
+                        <Typography className={classnames(classes.listItemText, {
                                 [classes.active]: isActive
                             })}>
                             {name}
index f2ec2d6439024c066eebb2a0b8a18a0ec4fba901..64ffc396923ce6d097e05fb89317070ff6470488 100644 (file)
@@ -58,7 +58,6 @@ export const SearchInput = withStyles(styles)(
         timeout: number;
 
         render() {
-            const { classes } = this.props;
             return <form onSubmit={this.handleSubmit}>
                 <FormControl>
                     <InputLabel>Search</InputLabel>
index 4a25ea7019f86bc7a9391f6c269e8d10a9c2e865..a0f61990808219900ab63170e8299492706f99ac 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { WrappedFieldProps } from 'redux-form';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, MenuItem, FormHelperText } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, FormHelperText } from '@material-ui/core';
 
 type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
 
diff --git a/src/components/warning/warning.tsx b/src/components/warning/warning.tsx
new file mode 100644 (file)
index 0000000..9a49ff0
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { ErrorIcon } from "~/components/icon/icon";
+import { invalidNamingRules } from "~/validators/valid-name";
+import { Tooltip } from "@material-ui/core";
+
+interface WarningComponentProps {
+    text: string;
+    rules: RegExp[];
+    message: string;
+}
+
+export const WarningComponent = ({ text, rules, message }: WarningComponentProps) =>
+    rules.find(aRule => text.match(aRule) !== null)
+    ? message
+        ? <Tooltip title={message}><ErrorIcon /></Tooltip>
+        : <ErrorIcon />
+    : null;
+
+interface IllegalNamingWarningProps {
+    name: string;
+}
+
+export const IllegalNamingWarning = ({ name }: IllegalNamingWarningProps) =>
+    <WarningComponent
+        text={name} rules={invalidNamingRules}
+        message="Names being '.', '..' or including '/' cause issues with WebDAV, please edit it to something different." />;
\ No newline at end of file
index ee174b2c5325866878d6d7012552e64a1ae85478..aa372fa5081c0eb2c4d05ea1b22babed765ece8a 100644 (file)
@@ -13,6 +13,7 @@ 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 { AddSession } from "~/views-components/add-session/add-session";
 import { initAuth } from "~/store/auth/auth-action";
 import { createServices } from "~/services/services";
 import { MuiThemeProvider } from '@material-ui/core/styles';
@@ -40,13 +41,11 @@ import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/curre
 import { processResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
 import { trashedCollectionActionSet } from '~/views-components/context-menu/action-sets/trashed-collection-action-set';
-import { ContainerRequestState } from '~/models/container-request';
-import { MountKind } from '~/models/mount-types';
 import { setBuildInfo } from '~/store/app-info/app-info-actions';
 import { getBuildInfo } from '~/common/app-info';
 import { DragDropContextProvider } from 'react-dnd';
 import HTML5Backend from 'react-dnd-html5-backend';
-import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions';
+import { initAdvancedFormProjectsTree } from '~/store/search-bar/search-bar-actions';
 import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set';
 import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set';
 import { keepServiceActionSet } from '~/views-components/context-menu/action-sets/keep-service-action-set';
@@ -62,6 +61,7 @@ import { loadFileViewersConfig } from '~/store/file-viewers/file-viewers-actions
 import { collectionAdminActionSet } from '~/views-components/context-menu/action-sets/collection-admin-action-set';
 import { processResourceAdminActionSet } from '~/views-components/context-menu/action-sets/process-resource-admin-action-set';
 import { projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set';
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -100,8 +100,20 @@ fetchConfig()
                 store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
             },
             errorFn: (id, error) => {
-                // console.error("Backend error:", error);
-                // store.dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Backend error", kind: SnackbarKind.ERROR }));
+                console.error("Backend error:", error);
+                if (error.errors) {
+                    store.dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: `${error.errors[0]}`,
+                        kind: SnackbarKind.ERROR,
+                        hideDuration: 8000
+                    }));
+                } else {
+                    store.dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: `${error.message}`,
+                        kind: SnackbarKind.ERROR,
+                        hideDuration: 8000
+                    }));
+                }
             }
         });
         const store = configureStore(history, services);
@@ -114,6 +126,7 @@ fetchConfig()
         store.dispatch(loadFileViewersConfig);
 
         const TokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={true} {...props} />;
+        const AddSessionComponent = (props: any) => <AddSession {...props} />;
         const FedTokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={false} {...props} />;
         const MainPanelComponent = (props: any) => <MainPanel {...props} />;
 
@@ -125,6 +138,7 @@ fetchConfig()
                             <Switch>
                                 <Route path={Routes.TOKEN} component={TokenComponent} />
                                 <Route path={Routes.FED_LOGIN} component={FedTokenComponent} />
+                                <Route path={Routes.ADD_SESSION} component={AddSessionComponent} />
                                 <Route path={Routes.ROOT} component={MainPanelComponent} />
                             </Switch>
                         </ConnectedRouter>
@@ -148,186 +162,9 @@ const initListener = (history: History, store: RootStore, services: ServiceRepos
             await store.dispatch(loadWorkbench());
             addRouteChangeHandlers(history, store);
             // ToDo: move to searchBar component
-            store.dispatch(initAdvanceFormProjectsTree());
+            store.dispatch(initAdvancedFormProjectsTree());
         }
     };
 };
 
-const createDirectoriesArrayCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'Directories array collector',
-        description: 'Workflow for collecting directories array',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"multiple_collections\":\n          $(inputs.multiple_collections)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: array\n      items: Directory\n    id: '#input_collector.cwl/multiple_collections'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: array\n      items: Directory\n    label: Multiple Collections\n    doc: This should allow for selecting multiple collections.\n    id: '#main/multiple_collections'\n    default:\n    - class: Directory\n      location: keep:1e1682585d576f031b2d8b4944f989ee+57\n      basename: 1e1682585d576f031b2d8b4944f989ee+57\n    - class: Directory\n      location: keep:326f692370e9e121fcbd013796f7352a+57\n      basename: 326f692370e9e121fcbd013796f7352a+57\n  \n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/multiple_collections'\n      id: '#main/input_collector/multiple_collections'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createPrimitiveArraysCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'String, Int and Float arrays collector',
-        description: 'Workflow for collecting primitive data arrays',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"string array\":\n          $(inputs.example_string_array)\n        \"int array\":\n          $(inputs.example_int_array)\n        \"float array\":\n          $(inputs.example_float_array)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: array\n      items: string\n    id: '#input_collector.cwl/example_string_array'\n  - type:\n      type: array\n      items: int\n    id: '#input_collector.cwl/example_int_array'\n  - type:\n      type: array\n      items: float\n    id: '#input_collector.cwl/example_float_array'\n  \n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: array\n      items: string\n    label: Freetext Array\n    doc: This should allow for entering multiple strings.\n    id: '#main/example_string_array'\n    default:\n    - This is the first string\n    - This is the second string\n  - type:\n      type: array\n      items: int\n    label: Integer Array\n    doc: This should allow for entering multiple integers.\n    id: '#main/example_int_array'\n    default:\n    - 3\n    - 6\n  - type:\n      type: array\n      items: float\n    label: Float Array\n    doc: This should allow for entering multiple floats.\n    id: '#main/example_float_array'\n    default:\n    - 3.33\n    - 66.6\n\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/example_string_array'\n      id: '#main/input_collector/example_string_array'\n    - source: '#main/example_int_array'\n      id: '#main/input_collector/example_int_array'\n    - source: '#main/example_float_array'\n      id: '#main/input_collector/example_float_array'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createFilesArrayCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'Files array collector',
-        description: 'Workflow for collecting files array',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"multiple_files\":\n          $(inputs.multiple_files)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: array\n      items: File\n    id: '#input_collector.cwl/multiple_files'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [cat]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: array\n      items: File\n    label: Multiple Files\n    doc: This should allow for selecting multiple files.\n    id: '#main/multiple_files'\n    default:\n      - class: File\n        location: keep:af831660d820bcbb98f473355e6e1b85+67/fileA\n        basename: fileA\n        nameroot: fileA\n        nameext: ''\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/multiple_files'\n      id: '#main/input_collector/multiple_files'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createPrimitivesCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'Primitive values collector',
-        description: 'Workflow for collecting primitive values',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"flag\":\n          $(inputs.example_flag)\n        \"string\":\n          $(inputs.example_string)\n        \"int\":\n          $(inputs.example_int)\n        \"long\":\n          $(inputs.example_long)\n        \"float\":\n          $(inputs.example_float)\n        \"double\":\n          $(inputs.example_double)\n    class: InitialWorkDirRequirement\n  inputs:\n  - type: double\n    id: '#input_collector.cwl/example_double'\n  - type: boolean\n    id: '#input_collector.cwl/example_flag'\n  - type: float\n    id: '#input_collector.cwl/example_float'\n  - type: int\n    id: '#input_collector.cwl/example_int'\n  - type: long\n    id: '#input_collector.cwl/example_long'\n  - type: string\n    id: '#input_collector.cwl/example_string'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: Workflw for collecting primitive values\n  inputs:\n  - type: double\n    label: Double value\n    doc: This should allow for entering a decimal number (64-bit).\n    id: '#main/example_double'\n    default: 0.3333333333333333\n  - type: boolean\n    label: Boolean Flag\n    doc: This should render as in checkbox.\n    id: '#main/example_flag'\n    default: true\n  - type: float\n    label: Float value\n    doc: This should allow for entering a decimal number (32-bit).\n    id: '#main/example_float'\n    default: 0.15625\n  - type: int\n    label: Integer Number\n    doc: This should allow for entering a number (32-bit signed).\n    id: '#main/example_int'\n    default: 2147483647\n  - type: long\n    label: Long Number\n    doc: This should allow for entering a number (64-bit signed).\n    id: '#main/example_long'\n    default: 9223372036854775807\n  - type: string\n    label: Freetext\n    doc: This should allow for entering an arbitrary char sequence.\n    id: '#main/example_string'\n    default: This is a string\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/example_double'\n      id: '#main/input_collector/example_double'\n    - source: '#main/example_flag'\n      id: '#main/input_collector/example_flag'\n    - source: '#main/example_float'\n      id: '#main/input_collector/example_float'\n    - source: '#main/example_int'\n      id: '#main/input_collector/example_int'\n    - source: '#main/example_long'\n      id: '#main/input_collector/example_long'\n    - source: '#main/example_string'\n      id: '#main/input_collector/example_string'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createEnumCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'Enum values collector',
-        description: 'Workflow for collecting enum values',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"enum_type\":\n          $(inputs.enum_type)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: enum\n      symbols: ['#input_collector.cwl/enum_type/OTU table', '#input_collector.cwl/enum_type/Pathway\n          table', '#input_collector.cwl/enum_type/Function table', '#input_collector.cwl/enum_type/Ortholog\n          table']\n    id: '#input_collector.cwl/enum_type'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: enum\n      symbols: ['#main/enum_type/OTU table', '#main/enum_type/Pathway table', '#main/enum_type/Function\n          table', '#main/enum_type/Ortholog table']\n      name: '#enum_typef4179c7f-45f9-482d-a5db-1abb86698384'\n    label: Enumeration Type\n    doc: This should render as a drop-down menu.\n    id: '#main/enum_type'\n    default: OTU table\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/enum_type'\n      id: '#main/input_collector/enum_type'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createFilesCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'File values collector',
-        description: 'Workflow for collecting file values',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"single_file\":\n          $(inputs.single_file.basename)\n        \"optional_file\":\n          $(inputs.optional_file.basename)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n    - 'null'\n    - File\n    id: '#input_collector.cwl/optional_file'\n  - type:\n    - 'null'\n    - File\n    id: '#input_collector.cwl/optional_file_missing_label'\n  - type: File\n    id: '#input_collector.cwl/single_file'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n    - 'null'\n    - File\n    label: Single File (Optional)\n    doc: This should allow for single File selection only. Input should be marked\n      as optional and not enforced by form validation.\n    id: '#main/optional_file'\n    default:\n      class: File\n      location: keep:af831660d820bcbb98f473355e6e1b85+67/fileA\n      basename: fileA\n      nameroot: fileA\n      nameext: ''\n  - type:\n    - 'null'\n    - File\n    doc: Label should be the input field name because of missing label.\n    id: '#main/optional_file_missing_label'\n  - type: File\n    label: Single File\n    doc: This should allow for single File selection only.\n    id: '#main/single_file'\n    default:\n      class: File\n      location: keep:af831660d820bcbb98f473355e6e1b85+67/fileA\n      basename: fileA\n      nameroot: fileA\n      nameext: ''\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/optional_file'\n      id: '#main/input_collector/optional_file'\n    - source: '#main/single_file'\n      id: '#main/input_collector/single_file'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createCollectionCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
-    workflowService.create({
-        name: 'Collection value collector',
-        description: 'Workflow for collecting a collecion',
-        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"collection\":\n          $(inputs.collection.location)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type: Directory\n    id: '#input_collector.cwl/collection'\n\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type: Directory\n    label: Single Collection\n    doc: This should allow for single Collection selection only.\n    id: '#main/collection'\n    default:\n      class: Directory\n      location: keep:af831660d820bcbb98f473355e6e1b85+67\n      basename: af831660d820bcbb98f473355e6e1b85+67\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/collection'\n      id: '#main/input_collector/collection'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
-    });
-};
-
-const createSampleProcess = ({ containerRequestService }: ServiceRepository) => {
-    containerRequestService.create({
-        ownerUuid: 'c97qk-j7d0g-s3ngc1z0748hsmf',
-        name: 'Simple process 7',
-        state: ContainerRequestState.COMMITTED,
-        mounts: {
-            '/var/spool/cwl': {
-                kind: MountKind.COLLECTION,
-                writable: true,
-            },
-            'stdout': {
-                kind: MountKind.MOUNTED_FILE,
-                path: '/var/spool/cwl/cwl.output.json'
-            },
-            '/var/lib/cwl/workflow.json': {
-                kind: MountKind.JSON,
-                content: {
-                    "cwlVersion": "v1.0",
-                    "$graph": [
-                        {
-                            "class": "CommandLineTool",
-                            "requirements": [
-                                {
-                                    "listing": [
-                                        {
-                                            "entryname": "input_collector.log",
-                                            "entry": "$(inputs.single_file.basename)\n"
-                                        }
-                                    ],
-                                    "class": "InitialWorkDirRequirement"
-                                }
-                            ],
-                            "inputs": [
-                                {
-                                    "type": "File",
-                                    "id": "#input_collector.cwl/single_file"
-                                }
-                            ],
-                            "outputs": [
-                                {
-                                    "type": "File",
-                                    "outputBinding": {
-                                        "glob": "*"
-                                    },
-                                    "id": "#input_collector.cwl/output"
-                                }
-                            ],
-                            "baseCommand": [
-                                "echo"
-                            ],
-                            "id": "#input_collector.cwl"
-                        },
-                        {
-                            "class": "Workflow",
-                            "doc": "This is the description of the workflow",
-                            "inputs": [
-                                {
-                                    "type": "File",
-                                    "label": "Single File",
-                                    "doc": "This should allow for single File selection only.",
-                                    "id": "#main/single_file"
-                                }
-                            ],
-                            "outputs": [
-                                {
-                                    "type": "File",
-                                    "outputSource": "#main/input_collector/output",
-                                    "id": "#main/log_file"
-                                }
-                            ],
-                            "steps": [
-                                {
-                                    "run": "#input_collector.cwl",
-                                    "in": [
-                                        {
-                                            "source": "#main/single_file",
-                                            "id": "#main/input_collector/single_file"
-                                        }
-                                    ],
-                                    "out": [
-                                        "#main/input_collector/output"
-                                    ],
-                                    "id": "#main/input_collector"
-                                }
-                            ],
-                            "id": "#main"
-                        }
-                    ]
-                },
-            },
-            '/var/lib/cwl/cwl.input.json': {
-                kind: MountKind.JSON,
-                content: {
-                    "single_file": {
-                        "class": "File",
-                        "location": "keep:233454526794c0a2d56a305baeff3d30+145/1.txt",
-                        "basename": "fileA"
-                    }
-                },
-            }
-        },
-        runtimeConstraints: {
-            API: true,
-            vcpus: 1,
-            ram: 1073741824,
-        },
-        containerImage: 'arvados/jobs:1.1.4.20180618144723',
-        cwd: '/var/spool/cwl',
-        command: [
-            'arvados-cwl-runner',
-            '--local',
-            '--api=containers',
-            "--project-uuid=c97qk-j7d0g-s3ngc1z0748hsmf",
-            '/var/lib/cwl/workflow.json#main',
-            '/var/lib/cwl/cwl.input.json'
-        ],
-        outputPath: '/var/spool/cwl',
-        priority: 1,
-    });
-};
-
 // force build comment #1
index db5ac90be27547aaa3213dd80c52497c606b0c8b..4090278fcd575e20c97b1b4a3e9aa2b712ad8fdb 100644 (file)
@@ -5,9 +5,7 @@ import {GraphChange, SVGPlugin}                                     from '../plu
 import {
     StepModel,
     WorkflowInputParameterModel,
-    WorkflowOutputParameterModel,
-    WorkflowStepInputModel,
-    WorkflowStepOutputModel
+    WorkflowOutputParameterModel
 } from "cwlts/models";
 
 export class SVGArrangePlugin implements SVGPlugin {
index ca2b4b36d6965e42cba58467bf7041f9beee85b4..baa25c7af11f145b533e26c5d3d054dda4f751dc 100644 (file)
@@ -21,6 +21,8 @@ export interface CollectionResource extends TrashableResource {
     version: number;
     preserveVersion: boolean;
     unsignedManifestText?: string;
+    fileCount: number;
+    fileSizeTotal: number;
 }
 
 export const getCollectionUrl = (uuid: string) => {
index f34ede0afcddaaa5bdbd2e9279b881deb8bb8c8a..e18c8ecbb96c6b67652ee51f2245ba022eaddd17 100644 (file)
@@ -10,7 +10,7 @@ export interface GroupResource extends TrashableResource {
     groupClass: GroupClass | null;
     description: string;
     properties: any;
-    writeableBy: string[];
+    writableBy: string[];
     ensure_unique_name: boolean;
 }
 
index 52b29499bbc6f91e3752ffc137b4e3aadd4ea4c3..db87db18cd5b12401431d9c03744ce548842da06 100644 (file)
@@ -22,7 +22,7 @@ export type MountType =
 export interface CollectionMount {
     kind: MountKind.COLLECTION;
     uuid?: string;
-    portableDataHash?: string;
+    portable_data_hash?: string;
     path?: string;
     writable?: boolean;
 }
index 8723811565700dc2980e9040903ef81042e3f6e7..d0232b83d63414b92531db45b00bbe7bca8aecb6 100644 (file)
@@ -18,17 +18,17 @@ export interface NodeResource extends Resource {
 }
 
 export interface NodeInfo {
-    lastAction: string;
-    pingSecret: string;
-    ec2InstanceId: string;
-    slurmState?: string;
+    last_action: string;
+    ping_secret: string;
+    ec2_instance_id: string;
+    slurm_state?: string;
 }
 
 export interface NodeProperties {
-    cloudNode: CloudNode;
-    totalRamMb: number;
-    totalCpuCores: number;
-    totalScratchMb: number;
+    cloud_node: CloudNode;
+    total_ram_mb: number;
+    total_cpu_cores: number;
+    total_scratch_mb: number;
 }
 
 interface CloudNode {
index 9762d50411d45c40be0bb9efbc389e10e3e2e35e..b89e3bef51397077f9d67c4b8f83958300dc4b50 100644 (file)
@@ -9,6 +9,9 @@ import { WorkflowInputsData } from './workflow';
 
 export type ProcessResource = ContainerRequestResource;
 
+export const MOUNT_PATH_CWL_WORKFLOW = '/var/lib/cwl/workflow.json';
+export const MOUNT_PATH_CWL_INPUT = '/var/lib/cwl/cwl.input.json';
+
 export const createWorkflowMounts = (workflow: WorkflowResource, inputs: WorkflowInputsData): { [path: string]: MountType } => {
     return {
         '/var/spool/cwl': {
index 239a67cc1251e88d6b6968c89a7d9b878ac9b214..4708a9dac8e98a618fbc853e39a2d48c10cb367f 100644 (file)
@@ -58,8 +58,8 @@ export enum ResourceObjectType {
 }
 
 export const RESOURCE_UUID_PATTERN = '[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}';
-export const RESOURCE_UUID_REGEX = new RegExp(RESOURCE_UUID_PATTERN);
-export const COLLECTION_PDH_REGEX = /[a-f0-9]{32}\+\d+/;
+export const RESOURCE_UUID_REGEX = new RegExp("^" + RESOURCE_UUID_PATTERN + "$");
+export const COLLECTION_PDH_REGEX = /^[a-f0-9]{32}\+\d+$/;
 
 export const isResourceUuid = (uuid: string) =>
     RESOURCE_UUID_REGEX.test(uuid);
index a780fd3575c71e93efc1c267681a3b896a8d42a4..89101c6ea3dd52e35e00ff0ab8f8f576a8d4140e 100644 (file)
@@ -5,6 +5,6 @@
 export interface RuntimeConstraints {
     ram: number;
     vcpus: number;
-    keepCacheRam?: number;
+    keep_cache_ram?: number;
     API: boolean;
 }
index 50ce4156a13fe689cacd02efb95762a6c5f4afdb..f2167c970b424d785905bec65bac9fd74317549a 100644 (file)
@@ -5,5 +5,5 @@
 export interface SchedulingParameters {
     partitions?: string[];
     preemptible?: boolean;
-    maxRunTime?: number;
+    max_run_time?: number;
 }
index effaeed4c0e676882e6bccc9f445e9eb732d9483..c71faf2ff47c132453e923a8d021816865ef67ee 100644 (file)
@@ -4,7 +4,7 @@
 
 import { ResourceKind } from '~/models/resource';
 
-export type SearchBarAdvanceFormData = {
+export type SearchBarAdvancedFormData = {
     type?: ResourceKind;
     cluster?: string;
     projectUuid?: string;
@@ -19,5 +19,7 @@ export type SearchBarAdvanceFormData = {
 
 export interface PropertyValue {
     key: string;
+    keyID?: string;
     value: string;
+    valueID?: string;
 }
index 9a942967370f1241de198007359d6582ac594a2a..91a0d997606257391e0958f595dc3696475b9402 100644 (file)
@@ -12,9 +12,10 @@ export interface Session {
     clusterId: string;
     remoteHost: string;
     baseUrl: string;
-    username: string;
+    name: string;
     email: string;
     token: string;
+    uuid: string;
     loggedIn: boolean;
     status: SessionStatus;
     active: boolean;
index 9c229affe85cf720e19513d3c8b121527b4b1e58..f4e5854ad109a26fd2b46908484e2b0ad6c6ba0c 100644 (file)
@@ -11,7 +11,9 @@ export interface TagResource extends LinkResource {
 
 export interface TagProperty {
     key: string;
+    keyID?: string;
     value: string;
+    valueID?: string;
 }
 
 export enum TagTailType {
index 22a94f166403d4b1c54c7b1e242c5f684d6e155c..1e1041a1d37f5ec9bc990efd87d98870dd1bc609 100644 (file)
@@ -23,7 +23,7 @@ export const mockGroupResource = (data: Partial<GroupResource> = {}): GroupResou
     properties: "",
     trashAt: "",
     uuid: "",
-    writeableBy: [],
+    writableBy: [],
     ensure_unique_name: true,
     ...data
 });
index 60a95342eedbbe6b8337f16c84ea896e6f978dcd..de2f7b71a1b6861ccd6af42e262f652d8e7d67b0 100644 (file)
@@ -238,6 +238,9 @@ const getRootNodeChildrenIds = <T>(tree: Tree<T>) =>
 
 
 const addChild = (parentId: string, childId: string) => <T>(tree: Tree<T>): Tree<T> => {
+    if (childId === "") {
+        return tree;
+    }
     const node = getNode(parentId)(tree);
     if (node) {
         const children = node.children.some(id => id === childId)
index 2497864507787cef09d6d3914780b79136090d77..87a97dfcd1934b057369cee39bcfb82f389c05e0 100644 (file)
@@ -30,15 +30,8 @@ export const getUserFullname = (user?: User) => {
     return user ? `${user.firstName} ${user.lastName}` : "";
 };
 
-export interface UserResource extends Resource {
+export interface UserResource extends Resource, User {
     kind: ResourceKind.USER;
-    email: string;
-    username: string;
-    firstName: string;
-    lastName: string;
-    isAdmin: boolean;
-    prefs: UserPrefs;
     defaultOwnerUuid: string;
-    isActive: boolean;
     writableBy: string[];
 }
diff --git a/src/models/vocabulary.test.ts b/src/models/vocabulary.test.ts
new file mode 100644 (file)
index 0000000..87a8dfb
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Vocabulary from './vocabulary';
+import { pipe } from 'lodash/fp';
+
+describe('Vocabulary', () => {
+    let vocabulary: Vocabulary.Vocabulary;
+
+    beforeEach(() => {
+        vocabulary = {
+            strict_tags: false,
+            tags: {
+                IDKEYCOMMENT: {
+                    labels: []
+                },
+                IDKEYANIMALS: {
+                    strict: false,
+                    labels: [
+                        {label: "Animal" },
+                        {label: "Creature"}
+                    ],
+                    values: {
+                        IDVALANIMALS1: {
+                            labels: [
+                                {label: "Human"},
+                                {label: "Homo sapiens"}
+                            ]
+                        },
+                        IDVALANIMALS2: {
+                            labels: [
+                                {label: "Dog"},
+                                {label: "Canis lupus familiaris"}
+                            ]
+                        },
+                    }
+                },
+                IDKEYSIZES: {
+                    labels: [{label: "Sizes"}],
+                    values: {
+                        IDVALSIZES1: {
+                            labels: [{label: "Small"}]
+                        },
+                        IDVALSIZES2: {
+                            labels: [{label: "Medium"}]
+                        },
+                        IDVALSIZES3: {
+                            labels: [{label: "Large"}]
+                        },
+                        IDVALSIZES4: {
+                            labels: []
+                        }
+                    }
+                }
+            }
+        }
+    });
+
+    it('returns the list of tag keys', () => {
+        const tagKeys = Vocabulary.getTags(vocabulary);
+        // Alphabetically ordered by label
+        expect(tagKeys).toEqual([
+            {id: "IDKEYANIMALS", label: "Animal"},
+            {id: "IDKEYANIMALS", label: "Creature"},
+            {id: "IDKEYCOMMENT", label: "IDKEYCOMMENT"},
+            {id: "IDKEYSIZES", label: "Sizes"},
+        ]);
+    });
+
+    it('returns the tag values for a given key', () => {
+        const tagValues = Vocabulary.getTagValues('IDKEYSIZES', vocabulary);
+        // Alphabetically ordered by label
+        expect(tagValues).toEqual([
+            {id: "IDVALSIZES4", label: "IDVALSIZES4"},
+            {id: "IDVALSIZES3", label: "Large"},
+            {id: "IDVALSIZES2", label: "Medium"},
+            {id: "IDVALSIZES1", label: "Small"},
+        ])
+    });
+
+    it('returns an empty list of values for an non-existent key', () => {
+        const tagValues = Vocabulary.getTagValues('IDNONSENSE', vocabulary);
+        expect(tagValues).toEqual([]);
+    });
+
+    it('returns a key id for a given key label', () => {
+        const testCases = [
+            // Two labels belonging to the same ID
+            {keyLabel: 'Animal', expected: 'IDKEYANIMALS'},
+            {keyLabel: 'Creature', expected: 'IDKEYANIMALS'},
+            // Non-existent label returns empty string
+            {keyLabel: 'ThisKeyLabelDoesntExist', expected: ''},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyID(tc.keyLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns an key label for a given key id', () => {
+        const testCases = [
+            // ID with many labels return the first one
+            {keyID: 'IDKEYANIMALS', expected: 'Animal'},
+            // Key IDs without any labels or unknown keys should return the literal
+            // key from the API's response (that is, the key 'id')
+            {keyID: 'IDKEYCOMMENT', expected: 'IDKEYCOMMENT'},
+            {keyID: 'FOO', expected: 'FOO'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyLabel(tc.keyID, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value id for a given key id and value label', () => {
+        const testCases = [
+            // Key ID and value label known
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Human', expected: 'IDVALANIMALS1'},
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Homo sapiens', expected: 'IDVALANIMALS1'},
+            // Key ID known, value label unknown
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Dinosaur', expected: ''},
+            // Key ID unknown
+            {keyID: 'IDNONSENSE', valueLabel: 'Does not matter', expected: ''},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagValueID(tc.keyID, tc.valueLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value label for a given key & value id pair', () => {
+        const testCases = [
+            // Known key & value ids with multiple value labels: returns the first label
+            {keyId: 'IDKEYANIMALS', valueId: 'IDVALANIMALS1', expected: 'Human'},
+            // Values without label or unknown values should return the literal value from
+            // the API's response (that is, the value 'id')
+            {keyId: 'IDKEYSIZES', valueId: 'IDVALSIZES4', expected: 'IDVALSIZES4'},
+            {keyId: 'IDKEYCOMMENT', valueId: 'FOO', expected: 'FOO'},
+            {keyId: 'IDKEYANIMALS', valueId: 'BAR', expected: 'BAR'},
+            {keyId: 'IDKEYNONSENSE', valueId: 'FOOBAR', expected: 'FOOBAR'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueLabel = Vocabulary.getTagValueLabel(tc.keyId, tc.valueId, vocabulary);
+            expect(tagValueLabel).toEqual(tc.expected);
+        });
+    });
+});
index ea23ad2c79ffc05d56f3e0fa42572a37bc6a38ad..03f28c07bf9c5edc21de1e1996c52f28156c5f89 100644 (file)
@@ -5,20 +5,99 @@
 import { isObject, has, every } from 'lodash/fp';
 
 export interface Vocabulary {
-    strict: boolean;
+    strict_tags: boolean;
     tags: Record<string, Tag>;
 }
 
+export interface Label {
+    lang?: string;
+    label: string;
+}
+
+export interface TagValue {
+    labels: Label[];
+}
+
 export interface Tag {
     strict?: boolean;
-    values?: string[];
+    labels: Label[];
+    values?: Record<string, TagValue>;
+}
+
+export interface PropFieldSuggestion {
+    id: string;
+    label: string;
 }
 
 const VOCABULARY_VALIDATORS = [
     isObject,
-    has('strict'),
+    has('strict_tags'),
     has('tags'),
 ];
 
 export const isVocabulary = (value: any) =>
-    every(validator => validator(value), VOCABULARY_VALIDATORS);
\ No newline at end of file
+    every(validator => validator(value), VOCABULARY_VALIDATORS);
+
+export const isStrictTag = (tagKeyID: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagKeyID];
+    return tag ? tag.strict : false;
+};
+
+export const getTagValueID = (tagKeyID:string, tagValueLabel:string, vocabulary: Vocabulary) =>
+    (tagKeyID && vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].values)
+    ? Object.keys(vocabulary.tags[tagKeyID].values!).find(
+        k => vocabulary.tags[tagKeyID].values![k].labels.find(
+            l => l.label === tagValueLabel) !== undefined) || ''
+    : '';
+
+export const getTagValueLabel = (tagKeyID:string, tagValueID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] &&
+    vocabulary.tags[tagKeyID].values &&
+    vocabulary.tags[tagKeyID].values![tagValueID] &&
+    vocabulary.tags[tagKeyID].values![tagValueID].labels.length > 0
+        ? vocabulary.tags[tagKeyID].values![tagValueID].labels[0].label
+        : tagValueID;
+
+const compare = (a: PropFieldSuggestion, b: PropFieldSuggestion) => {
+    if (a.label < b.label) {return -1;}
+    if (a.label > b.label) {return 1;}
+    return 0;
+};
+
+export const getTagValues = (tagKeyID: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagKeyID];
+    const ret = tag && tag.values
+        ? Object.keys(tag.values).map(
+            tagValueID => tag.values![tagValueID].labels && tag.values![tagValueID].labels.length > 0
+                ? tag.values![tagValueID].labels.map(
+                    lbl => Object.assign({}, {"id": tagValueID, "label": lbl.label}))
+                : [{"id": tagValueID, "label": tagValueID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+    return ret;
+};
+
+export const getTags = ({ tags }: Vocabulary) => {
+    const ret = tags && Object.keys(tags)
+        ? Object.keys(tags).map(
+            tagID => tags[tagID].labels && tags[tagID].labels.length > 0
+                ? tags[tagID].labels.map(
+                    lbl => Object.assign({}, {"id": tagID, "label": lbl.label}))
+                : [{"id": tagID, "label": tagID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+    return ret;
+};
+
+export const getTagKeyID = (tagKeyLabel:string, vocabulary: Vocabulary) =>
+    Object.keys(vocabulary.tags).find(
+        k => vocabulary.tags[k].labels.find(
+            l => l.label === tagKeyLabel) !== undefined
+        ) || '';
+
+export const getTagKeyLabel = (tagKeyID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].labels.length > 0
+    ? vocabulary.tags[tagKeyID].labels[0].label
+    : tagKeyID;
index a858c0d7dec98e62d65087740bdf902d1153ec08..abc92c624d97ea72e84daef51d854af9f12d1009 100644 (file)
@@ -11,9 +11,8 @@ export interface WorkflowResource extends Resource {
     description: string;
     definition: string;
 }
-export interface WorkflowResoruceDefinition {
+export interface WorkflowResourceDefinition {
     cwlVersion: string;
-    graph?: Array<Workflow | CommandLineTool>;
     $graph?: Array<Workflow | CommandLineTool>;
 }
 export interface Workflow {
@@ -117,23 +116,24 @@ export type DirectoryArrayCommandInputParameter = GenericArrayCommandInputParame
 export type WorkflowInputsData = {
     [key: string]: boolean | number | string | File | Directory;
 };
-export const parseWorkflowDefinition = (workflow: WorkflowResource): WorkflowResoruceDefinition => {
+export const parseWorkflowDefinition = (workflow: WorkflowResource): WorkflowResourceDefinition => {
     const definition = safeLoad(workflow.definition);
     return definition;
 };
 
-export const getWorkflowInputs = (workflowDefinition: WorkflowResoruceDefinition) => {
-    if (workflowDefinition.graph) {
-        const mainWorkflow = workflowDefinition.graph.find(item => item.class === 'Workflow' && item.id === '#main');
-        return mainWorkflow
-            ? mainWorkflow.inputs
-            : undefined;
-    } else {
-        const mainWorkflow = workflowDefinition.$graph!.find(item => item.class === 'Workflow' && item.id === '#main');
-        return mainWorkflow
-            ? mainWorkflow.inputs
-            : undefined;
-    }
+export const getWorkflow = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition.$graph) { return undefined; }
+    const mainWorkflow = workflowDefinition.$graph.find(item => item.class === 'Workflow' && item.id === '#main');
+    return mainWorkflow
+        ? mainWorkflow
+        : undefined;
+};
+
+export const getWorkflowInputs = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition) { return undefined; }
+    return getWorkflow(workflowDefinition)
+        ? getWorkflow(workflowDefinition)!.inputs
+        : undefined;
 };
 
 export const getInputLabel = (input: CommandInputParameter) => {
index 08e0a03d058a0030bef3f864e640e8bf40b2a733..bb518d3fcfe803f109c56ac379646edc7b0f9c14 100644 (file)
@@ -19,6 +19,7 @@ export const Routes = {
     ROOT: '/',
     TOKEN: '/token',
     FED_LOGIN: '/fedtoken',
+    ADD_SESSION: '/add-session',
     PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`,
     COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
     PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
@@ -72,7 +73,13 @@ export const getNavUrl = (uuid: string, config: FederationConfig) => {
     } else if (config.remoteHostsConfig[cls]) {
         let u: URL;
         if (config.remoteHostsConfig[cls].workbench2Url) {
-            u = new URL(config.remoteHostsConfig[cls].workbench2Url || "");
+           /* NOTE: wb2 presently doesn't support passing api_token
+              to arbitrary page to set credentials, only through
+              api-token route.  So for navigation to work, user needs
+              to already be logged in.  In the future we want to just
+              request the records and display in the current
+              workbench instance making this redirect unnecessary. */
+            u = new URL(config.remoteHostsConfig[cls].workbench2Url);
         } else {
             u = new URL(config.remoteHostsConfig[cls].workbenchUrl);
             u.search = "api_token=" + config.sessions.filter((s) => s.clusterId === cls)[0].token;
@@ -159,7 +166,7 @@ export const matchTokenRoute = (route: string) =>
     matchPath(route, { path: Routes.TOKEN });
 
 export const matchFedTokenRoute = (route: string) =>
-    matchPath(route, {path: Routes.FED_LOGIN});
+    matchPath(route, { path: Routes.FED_LOGIN });
 
 export const matchUsersRoute = (route: string) =>
     matchPath(route, { path: Routes.USERS });
index d9b76b1e6ac867dd3576cee4aadb7b22fef66f32..d9656934cb80ff8ad9cd73177f2abfec0bc67c3e 100644 (file)
@@ -87,6 +87,6 @@ describe("FilterBuilder", () => {
             new FilterBuilder()
                 .addFullTextSearch('my custom search')
                 .getFilters()
-        ).toEqual(`["any","@@","my:*&custom:*&search"]`);
+        ).toEqual(`["any","ilike","%my%"],["any","ilike","%custom%"],["any","ilike","%search%"]`);
     });
 });
index e8992341098eb4b9ee506aa529a9a72a06b948fa..77fcef6fca096bf8a13a406f1b3e0b1bd1a8ed25 100644 (file)
@@ -56,10 +56,13 @@ export class FilterBuilder {
     }
 
     public addFullTextSearch(value: string) {
-        // Filter construction implementation taken from 
-        // https://dev.arvados.org/projects/arvados/repository/entry/apps/workbench/app/assets/javascripts/filterable.js
-        // https://dev.arvados.org/projects/arvados/repository/entry/apps/workbench/app/assets/javascripts/to_tsquery.js
-        return this.addCondition('any', '@@', value.replace(/[^-\w\.\/]+/g, ' ').trim().replace(/ /g, ':*&'));
+        const terms = value.trim().split(/(\s+)/);
+        terms.forEach(term => {
+            if (term !== " ") {
+                this.addCondition("any", "ilike", term, "%", "%");
+            }
+        });
+        return this;
     }
 
     public getFilters() {
index 2b4894015fc70f68c0b22975c35da94aa7d44c1f..d362508d186dabbb1d2347f576cc60bb14791f88 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { OrderBuilder } from "./order-builder";
 import { joinUrls } from "~/services/api/url-builder";
 
 describe("UrlBuilder", () => {
index a80d89ba146f374f329ff62f179217997fd34e6f..c6e93a8fe777210e45d8f8a4a1bc9f2abb7fec33 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { getUserFullname, User, UserPrefs, UserResource } from '~/models/user';
+import { getUserFullname, User, UserPrefs } from '~/models/user';
 import { AxiosInstance } from "axios";
 import { ApiActions } from "~/services/api/api-actions";
 import * as uuid from "uuid/v4";
@@ -43,7 +43,10 @@ export class AuthService {
 
     public saveApiToken(token: string) {
         localStorage.setItem(API_TOKEN_KEY, token);
-        localStorage.setItem(HOME_CLUSTER, token.split('/')[1].substr(0, 5));
+        const sp = token.split('/');
+        if (sp.length === 3) {
+            localStorage.setItem(HOME_CLUSTER, sp[1].substr(0, 5));
+        }
     }
 
     public removeApiToken() {
@@ -58,50 +61,6 @@ export class AuthService {
         return localStorage.getItem(HOME_CLUSTER) || undefined;
     }
 
-    public getUuid() {
-        return localStorage.getItem(USER_UUID_KEY) || undefined;
-    }
-
-    public getOwnerUuid() {
-        return localStorage.getItem(USER_OWNER_UUID_KEY) || undefined;
-    }
-
-    public getIsAdmin(): boolean {
-        return localStorage.getItem(USER_IS_ADMIN) === 'true';
-    }
-
-    public getIsActive(): boolean {
-        return localStorage.getItem(USER_IS_ACTIVE) === 'true';
-    }
-
-    public getUser(): User | undefined {
-        const email = localStorage.getItem(USER_EMAIL_KEY);
-        const firstName = localStorage.getItem(USER_FIRST_NAME_KEY);
-        const lastName = localStorage.getItem(USER_LAST_NAME_KEY);
-        const uuid = this.getUuid();
-        const ownerUuid = this.getOwnerUuid();
-        const isAdmin = this.getIsAdmin();
-        const isActive = this.getIsActive();
-        const username = localStorage.getItem(USER_USERNAME);
-        const prefs = JSON.parse(localStorage.getItem(USER_PREFS) || '{"profile": {}}');
-
-        return email && firstName && lastName && uuid && ownerUuid && username && prefs
-            ? { email, firstName, lastName, uuid, ownerUuid, isAdmin, isActive, username, prefs }
-            : undefined;
-    }
-
-    public saveUser(user: User | UserResource) {
-        localStorage.setItem(USER_EMAIL_KEY, user.email);
-        localStorage.setItem(USER_FIRST_NAME_KEY, user.firstName);
-        localStorage.setItem(USER_LAST_NAME_KEY, user.lastName);
-        localStorage.setItem(USER_UUID_KEY, user.uuid);
-        localStorage.setItem(USER_OWNER_UUID_KEY, user.ownerUuid);
-        localStorage.setItem(USER_IS_ADMIN, JSON.stringify(user.isAdmin));
-        localStorage.setItem(USER_IS_ACTIVE, JSON.stringify(user.isActive));
-        localStorage.setItem(USER_USERNAME, user.username);
-        localStorage.setItem(USER_PREFS, JSON.stringify(user.prefs));
-    }
-
     public removeUser() {
         localStorage.removeItem(USER_EMAIL_KEY);
         localStorage.removeItem(USER_FIRST_NAME_KEY);
@@ -114,10 +73,10 @@ export class AuthService {
         localStorage.removeItem(USER_PREFS);
     }
 
-    public login(uuidPrefix: string, homeCluster: string, remoteHosts: { [key: string]: string }) {
+    public login(uuidPrefix: string, homeCluster: string, loginCluster: string, remoteHosts: { [key: string]: string }) {
         const currentUrl = `${window.location.protocol}//${window.location.host}/token`;
         const homeClusterHost = remoteHosts[homeCluster];
-        window.location.assign(`https://${homeClusterHost}/login?${uuidPrefix !== homeCluster ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`);
+        window.location.assign(`https://${homeClusterHost}/login?${(uuidPrefix !== homeCluster && homeCluster !== loginCluster) ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`);
     }
 
     public logout() {
@@ -152,12 +111,6 @@ export class AuthService {
             });
     }
 
-    public getRootUuid() {
-        const uuid = this.getOwnerUuid();
-        const uuidParts = uuid ? uuid.split('-') : [];
-        return uuidParts.length > 1 ? `${uuidParts[0]}-${uuidParts[1]}` : undefined;
-    }
-
     public getSessions(): Session[] {
         try {
             const sessions = JSON.parse(localStorage.getItem("sessions") || '');
@@ -176,31 +129,39 @@ export class AuthService {
             clusterId: cfg.uuidPrefix,
             remoteHost: cfg.rootUrl,
             baseUrl: cfg.baseUrl,
-            username: getUserFullname(user),
+            name: getUserFullname(user),
             email: user ? user.email : '',
             token: this.getApiToken(),
             loggedIn: true,
             active: true,
+            uuid: user ? user.uuid : '',
             status: SessionStatus.VALIDATED
         } as Session;
-        const localSessions = this.getSessions();
+        const localSessions = this.getSessions().map(s => ({
+            ...s,
+            active: false,
+            status: SessionStatus.INVALIDATED
+        }));
+
         const cfgSessions = Object.keys(cfg.remoteHosts).map(clusterId => {
             const remoteHost = cfg.remoteHosts[clusterId];
             return {
                 clusterId,
                 remoteHost,
                 baseUrl: '',
-                username: '',
+                name: '',
                 email: '',
                 token: '',
                 loggedIn: false,
                 active: false,
+                uuid: '',
                 status: SessionStatus.INVALIDATED
             } as Session;
         });
         const sessions = [currentSession]
+            .concat(cfgSessions)
             .concat(localSessions)
-            .concat(cfgSessions);
+            .filter((r: Session) => r.clusterId !== "*");
 
         const uniqSessions = uniqBy(sessions, 'clusterId');
 
index 6f88a729aba7ebba03e73709fe5ca97189f64460..f8e7de9857c333d78fd08de5dc8425cf75789552 100644 (file)
@@ -5,8 +5,6 @@
 import { CollectionService } from "../collection-service/collection-service";
 import { parseKeepManifestText, stringifyKeepManifest } from "./collection-manifest-parser";
 import { mapManifestToCollectionFilesTree } from "./collection-manifest-mapper";
-import { CommonResourceService } from "~/services/common-service/common-resource-service";
-import * as _ from "lodash";
 
 export class CollectionFilesService {
 
@@ -40,8 +38,7 @@ export class CollectionFilesService {
                 : stream
         );
         const manifestText = stringifyKeepManifest(updatedManifest);
-        const data = { ...collection, manifestText };
-        return this.collectionService.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
+        return this.collectionService.update(collectionUuid, { manifestText });
     }
 
     async deleteFile(collectionUuid: string, file: { name: string, path: string }) {
@@ -58,11 +55,4 @@ export class CollectionFilesService {
         const manifestText = stringifyKeepManifest(updatedManifest);
         return this.collectionService.update(collectionUuid, { manifestText });
     }
-
-    renameTest() {
-        const u = this.renameFile('qr1hi-4zz18-n0sx074erl4p0ph', {
-            name: 'extracted2.txt.png',
-            path: ''
-        }, 'extracted-new.txt.png');
-    }
 }
index 619a4fc78582270558aa51ac07986ba578ed1c02..2e726d0bc8e0798fbae17df24f412f920d54906a 100644 (file)
@@ -2,16 +2,10 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createCollectionFilesTree, CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { 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);
@@ -41,16 +35,16 @@ export const extractFilesData = (document: Document) => {
                 .replace(collectionUrlPrefix, '')
                 .replace(nameSuffix, '');
 
-
+            const parentPath = directory.replace(/\/$/, '');
             const data = {
                 url,
                 id: [
                     collectionUuid ? collectionUuid : '',
-                    directory ? '/' + directory.replace(/^\//, '') : '',
+                    directory ? parentPath : '',
                     '/' + name
                 ].join(''),
                 name,
-                path: directory,
+                path: parentPath,
             };
 
             return getTagValue(element, 'D:resourcetype', '')
index 86b1aded7cf69ac280564a530eb5ebb8a0439f21..6eb9b5ba664f15892d570206051a93803050987b 100644 (file)
@@ -7,12 +7,9 @@ import { AxiosInstance } from "axios";
 import { CollectionFile, CollectionDirectory } from "~/models/collection-file";
 import { WebDAV } from "~/common/webdav";
 import { AuthService } from "../auth-service/auth-service";
-import { mapTreeValues } from "~/models/tree";
-import { parseFilesResponse } from "./collection-service-files-response";
-import { fileToArrayBuffer } from "~/common/file";
+import { extractFilesData } from "./collection-service-files-response";
 import { TrashableResourceService } from "~/services/common-service/trashable-resource-service";
 import { ApiActions } from "~/services/api/api-actions";
-import { snakeCase } from 'lodash';
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
@@ -24,8 +21,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     async files(uuid: string) {
         const request = await this.webdavClient.propfind(`c=${uuid}`);
         if (request.responseXML != null) {
-            const filesTree = parseFilesResponse(request.responseXML);
-            return mapTreeValues(this.extendFileURL)(filesTree);
+            return extractFilesData(request.responseXML);
         }
         return Promise.reject();
     }
@@ -55,7 +51,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         );
     }
 
-    private extendFileURL = (file: CollectionDirectory | CollectionFile) => {
+    extendFileURL = (file: CollectionDirectory | CollectionFile) => {
         const baseUrl = this.webdavClient.defaults.baseURL.endsWith('/')
             ? this.webdavClient.defaults.baseURL.slice(0, -1)
             : this.webdavClient.defaults.baseURL;
@@ -82,27 +78,4 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         };
         return this.webdavClient.upload(fileURL, [file], requestConfig);
     }
-
-    update(uuid: string, data: Partial<CollectionResource>) {
-        if (uuid && data && data.properties) {
-            const { properties } = data;
-            const mappedData = {
-                ...TrashableResourceService.mapKeys(snakeCase)(data),
-                properties,
-            };
-            return TrashableResourceService
-                .defaultResponse(
-                    this.serverApi
-                        .put<CollectionResource>(this.resourceType + uuid, mappedData),
-                    this.actions,
-                    false
-                );
-        }
-        return TrashableResourceService
-            .defaultResponse(
-                this.serverApi
-                    .put<CollectionResource>(this.resourceType + uuid, data && TrashableResourceService.mapKeys(snakeCase)(data)),
-                this.actions
-            );
-    }
 }
index 5a3bae25fdf005d71245ef821b6cea7693d03a8d..41a584fd78582056a279b2cd977b4731109e9bd8 100644 (file)
@@ -32,7 +32,7 @@ describe("CommonResourceService", () => {
 
     it("#create", async () => {
         axiosMock
-            .onPost("/resource/")
+            .onPost("/resource")
             .reply(200, { owner_uuid: "ownerUuidValue" });
 
         const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
@@ -44,7 +44,7 @@ describe("CommonResourceService", () => {
         axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
         const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
         await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
-        expect(axiosInstance.post).toHaveBeenCalledWith("/resource/", {owner_uuid: "ownerUuidValue"});
+        expect(axiosInstance.post).toHaveBeenCalledWith("/resource", {owner_uuid: "ownerUuidValue"});
     });
 
     it("#delete", async () => {
@@ -60,34 +60,52 @@ describe("CommonResourceService", () => {
     it("#get", async () => {
         axiosMock
             .onGet("/resource/uuid")
-            .reply(200, { modified_at: "now" });
+            .reply(200, {
+                modified_at: "now",
+                properties: {
+                    responsible_owner_uuid: "another_owner"
+                }
+            });
 
         const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
         const resource = await commonResourceService.get("uuid");
-        expect(resource).toEqual({ modifiedAt: "now" });
+        // Only first level keys are mapped to camel case
+        expect(resource).toEqual({
+            modifiedAt: "now",
+            properties: {
+                responsible_owner_uuid: "another_owner"
+            }
+        });
     });
 
     it("#list", async () => {
         axiosMock
-            .onGet("/resource/")
+            .onGet("/resource")
             .reply(200, {
                 kind: "kind",
                 offset: 2,
                 limit: 10,
                 items: [{
-                    modified_at: "now"
+                    modified_at: "now",
+                    properties: {
+                        is_active: true
+                    }
                 }],
                 items_available: 20
             });
 
         const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
         const resource = await commonResourceService.list({ limit: 10, offset: 1 });
+        // First level keys are mapped to camel case inside "items" arrays
         expect(resource).toEqual({
             kind: "kind",
             offset: 2,
             limit: 10,
             items: [{
-                modifiedAt: "now"
+                modifiedAt: "now",
+                properties: {
+                    is_active: true
+                }
             }],
             itemsAvailable: 20
         });
index 17c287d22ebaf6fad2d60b774d0fc400f29af5b7..d29ea15642f47dd51153c4d9ceb6e1986617c4db 100644 (file)
@@ -8,7 +8,7 @@ import { ApiActions } from "~/services/api/api-actions";
 import { CommonService } from "~/services/common-service/common-service";
 
 export enum CommonResourceServiceError {
-    UNIQUE_VIOLATION = 'UniqueViolation',
+    UNIQUE_NAME_VIOLATION = 'UniqueNameViolation',
     OWNERSHIP_CYCLE = 'OwnershipCycle',
     MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState',
     NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken',
@@ -27,7 +27,7 @@ export const getCommonResourceServiceError = (errorResponse: any) => {
         const error = errorResponse.errors.join('');
         switch (true) {
             case /UniqueViolation/.test(error):
-                return CommonResourceServiceError.UNIQUE_VIOLATION;
+                return CommonResourceServiceError.UNIQUE_NAME_VIOLATION;
             case /ownership cycle/.test(error):
                 return CommonResourceServiceError.OWNERSHIP_CYCLE;
             case /Mounts cannot be modified in state 'Final'/.test(error):
index 07ff398a4abdd7d4409bb67dd660e02f304af359..1c1e0a569d7e485080fa6c9f934177b188c4fc27 100644 (file)
@@ -38,7 +38,7 @@ export class CommonService<T> {
 
     constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions) {
         this.serverApi = serverApi;
-        this.resourceType = '/' + resourceType + '/';
+        this.resourceType = '/' + resourceType;
         this.actions = actions;
     }
 
@@ -54,7 +54,7 @@ export class CommonService<T> {
                         .map(key => [key, mapFn(key)])
                         .reduce((newValue, [key, newKey]) => ({
                             ...newValue,
-                            [newKey]: CommonService.mapKeys(mapFn)(value[key])
+                            [newKey]: (key === 'items') ? CommonService.mapKeys(mapFn)(value[key]) : value[key]
                         }), {});
                 case _.isArray(value):
                     return value.map(CommonService.mapKeys(mapFn));
@@ -93,7 +93,7 @@ export class CommonService<T> {
     delete(uuid: string): Promise<T> {
         return CommonService.defaultResponse(
             this.serverApi
-                .delete(this.resourceType + uuid),
+                .delete(this.resourceType + '/' + uuid),
             this.actions
         );
     }
@@ -101,7 +101,7 @@ export class CommonService<T> {
     get(uuid: string) {
         return CommonService.defaultResponse(
             this.serverApi
-                .get<T>(this.resourceType + uuid),
+                .get<T>(this.resourceType + '/' + uuid),
             this.actions
         );
     }
@@ -125,7 +125,7 @@ export class CommonService<T> {
     update(uuid: string, data: Partial<T>) {
         return CommonService.defaultResponse(
             this.serverApi
-                .put<T>(this.resourceType + uuid, data && CommonService.mapKeys(_.snakeCase)(data)),
+                .put<T>(this.resourceType + '/' + uuid, data && CommonService.mapKeys(_.snakeCase)(data)),
             this.actions
         );
     }
index 633b2fbd89cdf09041e4c93ee001916599763d62..5746bffb83136a6a80723d7da58b28ae0fe69301 100644 (file)
@@ -17,7 +17,7 @@ export class TrashableResourceService<T extends TrashableResource> extends Commo
     trash(uuid: string): Promise<T> {
         return CommonResourceService.defaultResponse(
             this.serverApi
-                .post(this.resourceType + `${uuid}/trash`),
+                .post(this.resourceType + `/${uuid}/trash`),
             this.actions
         );
     }
@@ -28,7 +28,7 @@ export class TrashableResourceService<T extends TrashableResource> extends Commo
         };
         return CommonResourceService.defaultResponse(
             this.serverApi
-                .post(this.resourceType + `${uuid}/untrash`, {
+                .post(this.resourceType + `/${uuid}/untrash`, {
                     params: CommonResourceService.mapKeys(_.snakeCase)(params)
                 }),
             this.actions
index 2e2ccd1c851bdc031d0f3f0aba8b9badb5873b33..e035ed5328fbecfef416212daa1183cb5d51b748 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { snakeCase } from 'lodash';
 import { CommonResourceService } from "~/services/common-service/common-resource-service";
 import { AxiosInstance } from "axios";
 import { ContainerRequestResource } from '~/models/container-request';
@@ -12,25 +11,4 @@ export class ContainerRequestService extends CommonResourceService<ContainerRequ
     constructor(serverApi: AxiosInstance, actions: ApiActions) {
         super(serverApi, "container_requests", actions);
     }
-
-    create(data?: Partial<ContainerRequestResource>) {
-        if (data) {
-            const { mounts } = data;
-            if (mounts) {
-                const mappedData = {
-                    ...CommonResourceService.mapKeys(snakeCase)(data),
-                    mounts,
-                };
-                return CommonResourceService
-                    .defaultResponse(
-                        this.serverApi.post<ContainerRequestResource>(this.resourceType, mappedData),
-                        this.actions);
-            }
-        }
-        return CommonResourceService
-            .defaultResponse(
-                this.serverApi
-                    .post<ContainerRequestResource>(this.resourceType, data && CommonResourceService.mapKeys(snakeCase)(data)),
-                this.actions);
-    }
 }
index a676557a78afd4e44c3b5c1ae771077b63541f49..281aa92152abaaa07f46b169b90ffef27308edaf 100644 (file)
@@ -9,7 +9,6 @@ import { AxiosInstance, AxiosRequestConfig } from "axios";
 import { CollectionResource } from "~/models/collection";
 import { ProjectResource } from "~/models/project";
 import { ProcessResource } from "~/models/process";
-import { ResourceKind } from '~/models/resource';
 import { TrashableResourceService } from "~/services/common-service/trashable-resource-service";
 import { ApiActions } from "~/services/api/api-actions";
 import { GroupResource } from "~/models/group";
@@ -47,36 +46,25 @@ export class GroupsService<T extends GroupResource = GroupResource> extends Tras
             filters: filters ? `[${filters}]` : undefined,
             order: order ? order : undefined
         };
-
-        const pathUrl = uuid ? `${uuid}/contents` : 'contents';
+        const pathUrl = uuid ? `/${uuid}/contents` : '/contents';
 
         const cfg: AxiosRequestConfig = { params: CommonResourceService.mapKeys(_.snakeCase)(params) };
         if (session) {
             cfg.baseURL = session.baseUrl;
+            cfg.headers = { 'Authorization': 'Bearer ' + session.token };
         }
 
         const response = await CommonResourceService.defaultResponse(
             this.serverApi.get(this.resourceType + pathUrl, cfg), this.actions, false
         );
 
-        const { items, ...res } = response;
-        const mappedItems = (items || []).map((item: GroupContentsResource) => {
-            const mappedItem = TrashableResourceService.mapKeys(_.camelCase)(item);
-            if (item.kind === ResourceKind.COLLECTION || item.kind === ResourceKind.PROJECT) {
-                const { properties } = item;
-                return { ...mappedItem, properties };
-            } else {
-                return mappedItem;
-            }
-        });
-        const mappedResponse = { ...TrashableResourceService.mapKeys(_.camelCase)(res) };
-        return { ...mappedResponse, items: mappedItems, clusterId: session && session.clusterId };
+        return { ...TrashableResourceService.mapKeys(_.camelCase)(response), clusterId: session && session.clusterId };
     }
 
     shared(params: SharedArguments = {}): Promise<ListResults<GroupContentsResource>> {
         return CommonResourceService.defaultResponse(
             this.serverApi
-                .get(this.resourceType + 'shared', { params }),
+                .get(this.resourceType + '/shared', { params }),
             this.actions
         );
     }
index 42fae3654a224f74f58fe8e86bbf3e95fd7ad7c4..b7298a8533d8b8e6e67f2abd3711f5c897463f22 100644 (file)
@@ -49,9 +49,9 @@ export class LinkAccountService {
             redirect_to_new_user: true
         };
         return CommonService.defaultResponse(
-            this.serverApi.post('/users/merge/', params),
+            this.serverApi.post('/users/merge', params),
             this.actions,
             false
         );
     }
-}
\ No newline at end of file
+}
index 9052360627c6f68ff0d95253efa6b0f4ae88bc55..aac04d96359dffb382e710bcd09a4b3e5cc5de98 100644 (file)
@@ -18,7 +18,7 @@ describe("CommonResourceService", () => {
         axiosInstance.post = jest.fn(() => Promise.resolve({ data: {} }));
         const projectService = new ProjectService(axiosInstance, actions);
         const resource = await projectService.create({ name: "nameValue" });
-        expect(axiosInstance.post).toHaveBeenCalledWith("/groups/", {
+        expect(axiosInstance.post).toHaveBeenCalledWith("/groups", {
             name: "nameValue",
             group_class: "project"
         });
@@ -28,7 +28,7 @@ describe("CommonResourceService", () => {
         axiosInstance.get = jest.fn(() => Promise.resolve({ data: {} }));
         const projectService = new ProjectService(axiosInstance, actions);
         const resource = await projectService.list();
-        expect(axiosInstance.get).toHaveBeenCalledWith("/groups/", {
+        expect(axiosInstance.get).toHaveBeenCalledWith("/groups", {
             params: {
                 filters: "[" + new FilterBuilder()
                     .addEqual("groupClass", "project")
index 5c686aae67fd5db06251071bacbc591b5c5f0f51..0665fa660bae0df9c91c04cbe8f7ef58d2978b84 100644 (file)
@@ -7,8 +7,6 @@ import { ProjectResource } from "~/models/project";
 import { GroupClass } from "~/models/group";
 import { ListArguments } from "~/services/common-service/common-service";
 import { FilterBuilder, joinFilters } from "~/services/api/filter-builder";
-import { TrashableResourceService } from '~/services/common-service/trashable-resource-service';
-import { snakeCase } from 'lodash';
 export class ProjectService extends GroupsService<ProjectResource> {
 
     create(data: Partial<ProjectResource>) {
@@ -16,29 +14,6 @@ export class ProjectService extends GroupsService<ProjectResource> {
         return super.create(projectData);
     }
 
-    update(uuid: string, data: Partial<ProjectResource>) {
-        if (uuid && data && data.properties) {
-            const { properties } = data;
-            const mappedData = {
-                ...TrashableResourceService.mapKeys(snakeCase)(data),
-                properties,
-            };
-            return TrashableResourceService
-                .defaultResponse(
-                    this.serverApi
-                        .put<ProjectResource>(this.resourceType + uuid, mappedData),
-                    this.actions,
-                    false
-                );
-        }
-        return TrashableResourceService
-            .defaultResponse(
-                this.serverApi
-                    .put<ProjectResource>(this.resourceType + uuid, data && TrashableResourceService.mapKeys(snakeCase)(data)),
-                this.actions
-            );
-    }
-
     list(args: ListArguments = {}) {
         return super.list({
             ...args,
index 84d120a89c52dacc835256b8f39e6f56ac0f8212..c6cfe786882ea7665eb4d83e49987eabb74bf37e 100644 (file)
@@ -2,11 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 
 export class SearchService {
     private recentQueries = this.getRecentQueries();
-    private savedQueries: SearchBarAdvanceFormData[] = this.getSavedQueries();
+    private savedQueries: SearchBarAdvancedFormData[] = this.getSavedQueries();
 
     saveRecentQuery(query: string) {
         if (this.recentQueries.length >= MAX_NUMBER_OF_RECENT_QUERIES) {
@@ -20,19 +20,19 @@ export class SearchService {
         return JSON.parse(localStorage.getItem('recentQueries') || '[]');
     }
 
-    saveQuery(data: SearchBarAdvanceFormData) {
+    saveQuery(data: SearchBarAdvancedFormData) {
         this.savedQueries.push({...data});
         localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
     }
 
-    editSavedQueries(data: SearchBarAdvanceFormData) {
+    editSavedQueries(data: SearchBarAdvancedFormData) {
         const itemIndex = this.savedQueries.findIndex(item => item.queryName === data.queryName);
         this.savedQueries[itemIndex] = {...data};
         localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
     }
 
     getSavedQueries() {
-        return JSON.parse(localStorage.getItem('savedQueries') || '[]') as SearchBarAdvanceFormData[];
+        return JSON.parse(localStorage.getItem('savedQueries') || '[]') as SearchBarAdvancedFormData[];
     }
 
     deleteSavedQuery(id: number) {
index dd3178790a05bdca84c7456418d85562286f1a66..af547deccfd81c8f8a00af0a3d2a35cbd0c81b82 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import Axios from "axios";
+import { AxiosInstance } from "axios";
 import { ApiClientAuthorizationService } from '~/services/api-client-authorization-service/api-client-authorization-service';
 import { AuthService } from "./auth-service/auth-service";
 import { GroupsService } from "./groups-service/groups-service";
@@ -35,8 +36,26 @@ import { LinkAccountService } from "./link-account-service/link-account-service"
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
-export const createServices = (config: Config, actions: ApiActions) => {
-    const apiClient = Axios.create();
+export function setAuthorizationHeader(services: ServiceRepository, token: string) {
+    services.apiClient.defaults.headers.common = {
+        Authorization: `Bearer ${token}`
+    };
+    services.webdavClient.defaults.headers = {
+        Authorization: `Bearer ${token}`
+    };
+}
+
+export function removeAuthorizationHeader(services: ServiceRepository) {
+    delete services.apiClient.defaults.headers.common;
+    delete services.webdavClient.defaults.headers.common;
+}
+
+export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => {
+    // Need to give empty 'headers' object or it will create an
+    // instance with a reference to the global default headers object,
+    // which is very bad because that means setAuthorizationHeader
+    // would update the global default instead of the instance default.
+    const apiClient = useApiClient || Axios.create({ headers: {} });
     apiClient.defaults.baseURL = config.baseUrl;
 
     const webdavClient = new WebDAV();
index a69203dc5bece0c4c3e1f29aceba92c3de998849..d8c7fe3d94f24d6a9f276947dc58a56ef11c9c81 100644 (file)
@@ -11,4 +11,20 @@ export class UserService extends CommonResourceService<UserResource> {
     constructor(serverApi: AxiosInstance, actions: ApiActions) {
         super(serverApi, "users", actions);
     }
+
+    activate(uuid: string) {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .post(this.resourceType + `/${uuid}/activate`),
+            this.actions
+        );
+    }
+
+    unsetup(uuid: string) {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .post(this.resourceType + `/${uuid}/unsetup`),
+            this.actions
+        );
+    }
 }
index b1f0f983eaf8b78cc6c669ea715ab2a3643ea610..be82547b813c9e9161a5ff834aa4c5cfceb8f438 100644 (file)
@@ -413,7 +413,7 @@ const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => {
 "container_count_max": ${stringify(containerCountMax)},
 "mounts": ${stringifyObject(mounts)},
 "runtime_constraints": ${stringifyObject(runtimeConstraints)},
-"container_image": "${stringify(containerImage)}",
+"container_image": ${stringify(containerImage)},
 "environment": ${stringifyObject(environment)},
 "cwd": ${stringify(cwd)},
 "command": ${stringifyObject(command)},
@@ -435,7 +435,7 @@ const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => {
 const collectionApiResponse = (apiResponse: CollectionResource) => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, portableDataHash, replicationDesired,
         replicationConfirmedAt, replicationConfirmed, manifestText, deleteAt, trashAt, isTrashed, storageClassesDesired,
-        storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion } = apiResponse;
+        storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion, fileCount, fileSizeTotal } = apiResponse;
     const response = `
 "uuid": "${uuid}",
 "owner_uuid": "${ownerUuid}",
@@ -457,15 +457,17 @@ const collectionApiResponse = (apiResponse: CollectionResource) => {
 "storage_classes_desired": ${JSON.stringify(storageClassesDesired, null, 2)},
 "storage_classes_confirmed": ${JSON.stringify(storageClassesConfirmed, null, 2)},
 "storage_classes_confirmed_at": ${stringify(storageClassesConfirmedAt)},
-"currentVersionUuid": ${stringify(currentVersionUuid)},
+"current_version_uuid": ${stringify(currentVersionUuid)},
 "version": ${version},
-"preserveVersion": ${preserveVersion}`;
+"preserve_version": ${preserveVersion},
+"file_count": ${fileCount},
+"file_size_total": ${fileSizeTotal}`;
 
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
 const groupRequestApiResponse = (apiResponse: ProjectResource) => {
-    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, groupClass, trashAt, isTrashed, deleteAt, properties, writeableBy } = apiResponse;
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, groupClass, trashAt, isTrashed, deleteAt, properties, writableBy } = apiResponse;
     const response = `
 "uuid": "${uuid}",
 "owner_uuid": "${ownerUuid}",
@@ -480,7 +482,7 @@ const groupRequestApiResponse = (apiResponse: ProjectResource) => {
 "is_trashed": ${stringify(isTrashed)},
 "delete_at": ${stringify(deleteAt)},
 "properties": ${stringifyObject(properties)},
-"witable_by": ${stringifyObject(writeableBy)}`;
+"writable_by": ${stringifyObject(writableBy)}`;
 
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
index b889e9cf39d7d301a0c5e8e4b8e70859e7d7c3ff..c1b97adc3ea0faa1e9b685832150cfe59bf119b8 100644 (file)
 import { Dispatch } from "redux";
 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
 import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "~/services/services";
 import Axios from "axios";
 import { getUserFullname, User } from "~/models/user";
 import { authActions } from "~/store/auth/auth-action";
-import { Config, DISCOVERY_URL } from "~/common/config";
+import {
+    Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH,
+    buildConfig, mockClusterConfigJSON
+} from "~/common/config";
+import { normalizeURLPath } from "~/common/url";
 import { Session, SessionStatus } from "~/models/session";
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
-import { AuthService, UserDetailsResponse } from "~/services/auth-service/auth-service";
+import { AuthService } from "~/services/auth-service/auth-service";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import * as jsSHA from "jssha";
 
-const getRemoteHostBaseUrl = async (remoteHost: string): Promise<string | null> => {
+const getClusterConfig = async (origin: string): Promise<Config | null> => {
+    // Try the new public config endpoint
+    try {
+        const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
+        return buildConfig(config);
+    } catch { }
+
+    // Fall back to discovery document
+    try {
+        const config = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
+        return {
+            baseUrl: normalizeURLPath(config.baseUrl),
+            keepWebServiceUrl: config.keepWebServiceUrl,
+            remoteHosts: config.remoteHosts,
+            rootUrl: config.rootUrl,
+            uuidPrefix: config.uuidPrefix,
+            websocketUrl: config.websocketUrl,
+            workbenchUrl: config.workbenchUrl,
+            workbench2Url: config.workbench2Url,
+            loginCluster: "",
+            vocabularyUrl: "",
+            fileViewersConfigUrl: "",
+            clusterConfig: mockClusterConfigJSON({})
+        };
+    } catch { }
+
+    return null;
+};
+
+const getRemoteHostConfig = async (remoteHost: string): Promise<Config | null> => {
     let url = remoteHost;
     if (url.indexOf('://') < 0) {
         url = 'https://' + url;
     }
     const origin = new URL(url).origin;
-    let baseUrl: string | null = null;
-
-    try {
-        const resp = await Axios.get<Config>(`${origin}/${DISCOVERY_URL}`);
-        baseUrl = resp.data.baseUrl;
-    } catch (err) {
-        try {
-            const resp = await Axios.get<any>(`${origin}/status.json`);
-            baseUrl = resp.data.apiBaseURL;
-        } catch (err) {
-        }
-    }
 
-    if (baseUrl && baseUrl[baseUrl.length - 1] === '/') {
-        baseUrl = baseUrl.substr(0, baseUrl.length - 1);
+    // Maybe it is an API server URL, try fetching config and discovery doc
+    let r = await getClusterConfig(origin);
+    if (r !== null) {
+        return r;
     }
 
-    return baseUrl;
-};
-
-const getUserDetails = async (baseUrl: string, token: string): Promise<UserDetailsResponse> => {
-    const resp = await Axios.get<UserDetailsResponse>(`${baseUrl}/users/current`, {
-        headers: {
-            Authorization: `OAuth2 ${token}`
+    // Maybe it is a Workbench2 URL, try getting config.json
+    try {
+        r = await getClusterConfig((await Axios.get<any>(`${origin}/config.json`)).data.API_HOST);
+        if (r !== null) {
+            return r;
         }
-    });
-    return resp.data;
-};
-
-const getTokenUuid = async (baseUrl: string, token: string): Promise<string> => {
-    if (token.startsWith("v2/")) {
-        const uuid = token.split("/")[1];
-        return Promise.resolve(uuid);
-    }
+    } catch { }
 
-    const resp = await Axios.get(`${baseUrl}api_client_authorizations`, {
-        headers: {
-            Authorization: `OAuth2 ${token}`
-        },
-        data: {
-            filters: JSON.stringify([['api_token', '=', token]])
+    // Maybe it is a Workbench1 URL, try getting status.json
+    try {
+        r = await getClusterConfig((await Axios.get<any>(`${origin}/status.json`)).data.apiBaseURL);
+        if (r !== null) {
+            return r;
         }
-    });
+    } catch { }
 
-    return resp.data.items[0].uuid;
+    return null;
 };
 
-export const getSaltedToken = (clusterId: string, tokenUuid: string, token: string) => {
+const invalidV2Token = "Must be a v2 token";
+
+export const getSaltedToken = (clusterId: string, token: string) => {
     const shaObj = new jsSHA("SHA-1", "TEXT");
-    let secret = token;
-    if (token.startsWith("v2/")) {
-        secret = token.split("/")[2];
+    const [ver, uuid, secret] = token.split("/");
+    if (ver !== "v2") {
+        throw new Error(invalidV2Token);
     }
-    shaObj.setHMACKey(secret, "TEXT");
-    shaObj.update(clusterId);
-    const hmac = shaObj.getHMAC("HEX");
-    return `v2/${tokenUuid}/${hmac}`;
-};
-
-const clusterLogin = async (clusterId: string, baseUrl: string, activeSession: Session): Promise<{ user: User, token: string }> => {
-    const tokenUuid = await getTokenUuid(activeSession.baseUrl, activeSession.token);
-    const saltedToken = getSaltedToken(clusterId, tokenUuid, activeSession.token);
-    const user = await getUserDetails(baseUrl, saltedToken);
-    return {
-        user: {
-            firstName: user.first_name,
-            lastName: user.last_name,
-            uuid: user.uuid,
-            ownerUuid: user.owner_uuid,
-            email: user.email,
-            isAdmin: user.is_admin,
-            isActive: user.is_active,
-            username: user.username,
-            prefs: user.prefs
-        },
-        token: saltedToken
-    };
+    let salted = secret;
+    if (uuid.substr(0, 5) !== clusterId) {
+        shaObj.setHMACKey(secret, "TEXT");
+        shaObj.update(clusterId);
+        salted = shaObj.getHMAC("HEX");
+    }
+    return `v2/${uuid}/${salted}`;
 };
 
 export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active);
 
-export const validateCluster = async (remoteHost: string, clusterId: string, activeSession: Session): Promise<{ user: User; token: string, baseUrl: string }> => {
-    const baseUrl = await getRemoteHostBaseUrl(remoteHost);
-    if (!baseUrl) {
-        return Promise.reject(`Could not find base url for ${remoteHost}`);
-    }
-    const { user, token } = await clusterLogin(clusterId, baseUrl, activeSession);
-    return { baseUrl, user, token };
+export const validateCluster = async (config: Config, useToken: string):
+    Promise<{ user: User; token: string }> => {
+
+    const saltedToken = getSaltedToken(config.uuidPrefix, useToken);
+
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, saltedToken);
+
+    const user = await svc.authService.getUserDetails();
+    return {
+        user,
+        token: saltedToken,
+    };
 };
 
 export const validateSession = (session: Session, activeSession: Session) =>
     async (dispatch: Dispatch): Promise<Session> => {
         dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
         session.loggedIn = false;
-        try {
-            const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.clusterId, activeSession);
+
+        const setupSession = (baseUrl: string, user: User, token: string) => {
             session.baseUrl = baseUrl;
             session.token = token;
             session.email = user.email;
-            session.username = getUserFullname(user);
+            session.uuid = user.uuid;
+            session.name = getUserFullname(user);
             session.loggedIn = true;
-        } catch {
-            session.loggedIn = false;
-        } finally {
-            session.status = SessionStatus.VALIDATED;
-            dispatch(authActions.UPDATE_SESSION(session));
+        };
+
+        let fail: Error | null = null;
+        const config = await getRemoteHostConfig(session.remoteHost);
+        if (config !== null) {
+            dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+            try {
+                const { user, token } = await validateCluster(config, session.token);
+                setupSession(config.baseUrl, user, token);
+            } catch (e) {
+                fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
+                try {
+                    const { user, token } = await validateCluster(config, activeSession.token);
+                    setupSession(config.baseUrl, user, token);
+                    fail = null;
+                } catch (e2) {
+                    if (e.message === invalidV2Token) {
+                        fail = new Error(`Getting current user for ${session.remoteHost}: ${e2.message}`);
+                    }
+                }
+            }
+        } else {
+            fail = new Error(`Could not get config for ${session.remoteHost}`);
         }
+        session.status = SessionStatus.VALIDATED;
+        dispatch(authActions.UPDATE_SESSION(session));
+
+        if (fail) {
+            throw fail;
+        }
+
         return session;
     };
 
@@ -139,70 +169,126 @@ export const validateSessions = () =>
             dispatch(progressIndicatorActions.START_WORKING("sessionsValidation"));
             for (const session of sessions) {
                 if (session.status === SessionStatus.INVALIDATED) {
-                    await dispatch(validateSession(session, activeSession));
+                    try {
+                       /* Here we are dispatching a function, not an
+                          action.  This is legal (it calls the
+                          function with a 'Dispatch' object as the
+                          first parameter) but the typescript
+                          annotations don't understand this case, so
+                          we get an error from typescript unless
+                          override it using Dispatch<any>.  This
+                          pattern is used in a bunch of different
+                          places in Workbench2. */
+                        await dispatch(validateSession(session, activeSession));
+                    } catch (e) {
+                        dispatch(snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            kind: SnackbarKind.ERROR
+                        }));
+                    }
                 }
             }
-            services.authService.saveSessions(sessions);
+            services.authService.saveSessions(getState().auth.sessions);
             dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation"));
         }
     };
 
-export const addSession = (remoteHost: string) =>
+export const addSession = (remoteHost: string, token?: string, sendToLogin?: boolean) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const sessions = getState().auth.sessions;
         const activeSession = getActiveSession(sessions);
-        if (activeSession) {
-            const clusterId = remoteHost.match(/^(\w+)\./)![1];
-            if (sessions.find(s => s.clusterId === clusterId)) {
-                return Promise.reject("Cluster already exists");
+        let useToken: string | null = null;
+        if (token) {
+            useToken = token;
+        } else if (activeSession) {
+            useToken = activeSession.token;
+        }
+
+        if (useToken) {
+            const config = await getRemoteHostConfig(remoteHost);
+            if (!config) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: `Could not get config for ${remoteHost}`,
+                    kind: SnackbarKind.ERROR
+                }));
+                return;
             }
+
             try {
-                const { baseUrl, user, token } = await validateCluster(remoteHost, clusterId, activeSession);
+                dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+                const { user, token } = await validateCluster(config, useToken);
                 const session = {
                     loggedIn: true,
                     status: SessionStatus.VALIDATED,
                     active: false,
                     email: user.email,
-                    username: getUserFullname(user),
+                    name: getUserFullname(user),
+                    uuid: user.uuid,
+                    baseUrl: config.baseUrl,
+                    clusterId: config.uuidPrefix,
                     remoteHost,
-                    baseUrl,
-                    clusterId,
                     token
                 };
 
-                dispatch(authActions.ADD_SESSION(session));
+                if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
+                    await dispatch(authActions.UPDATE_SESSION(session));
+                } else {
+                    await dispatch(authActions.ADD_SESSION(session));
+                }
                 services.authService.saveSessions(getState().auth.sessions);
 
                 return session;
-            } catch (e) {
+            } catch {
+                if (sendToLogin) {
+                    const rootUrl = new URL(config.baseUrl);
+                    rootUrl.pathname = "";
+                    window.location.href = `${rootUrl.toString()}/login?return_to=` + encodeURI(`${window.location.protocol}//${window.location.host}/add-session?baseURL=` + encodeURI(rootUrl.toString()));
+                    return;
+                }
             }
         }
-        return Promise.reject("Could not validate cluster");
+        return Promise.reject(new Error("Could not validate cluster"));
     };
 
-export const toggleSession = (session: Session) =>
+
+export const removeSession = (clusterId: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        let s = { ...session };
+        await dispatch(authActions.REMOVE_SESSION(clusterId));
+        services.authService.saveSessions(getState().auth.sessions);
+    };
+
+export const toggleSession = (session: Session) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const s: Session = { ...session };
 
         if (session.loggedIn) {
             s.loggedIn = false;
+            dispatch(authActions.UPDATE_SESSION(s));
         } else {
             const sessions = getState().auth.sessions;
             const activeSession = getActiveSession(sessions);
             if (activeSession) {
-                s = await dispatch<any>(validateSession(s, activeSession)) as Session;
+                try {
+                    await dispatch(validateSession(s, activeSession));
+                } catch (e) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: e.message,
+                        kind: SnackbarKind.ERROR
+                    }));
+                    s.loggedIn = false;
+                    dispatch(authActions.UPDATE_SESSION(s));
+                }
             }
         }
 
-        dispatch(authActions.UPDATE_SESSION(s));
         services.authService.saveSessions(getState().auth.sessions);
     };
 
 export const initSessions = (authService: AuthService, config: Config, user: User) =>
     (dispatch: Dispatch<any>) => {
         const sessions = authService.buildSessions(config, user);
-        authService.saveSessions(sessions);
         dispatch(authActions.SET_SESSIONS(sessions));
+        dispatch(validateSessions());
     };
 
 export const loadSiteManagerPanel = () =>
index 1fbdfdfa63a1b5d49aeb1ff056f3aa22b3c4fe6d..6e9ddaab3278894909b5139c4440bf4fbcff60c4 100644 (file)
@@ -5,8 +5,9 @@
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from "~/services/services";
-import {snackbarActions, SnackbarKind} from "~/store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import { FormErrors, reset, startSubmit, stopSubmit } from "redux-form";
 import { KeyType } from "~/models/ssh-key";
 import {
@@ -14,9 +15,7 @@ import {
     getAuthorizedKeysServiceError
 } from "~/services/authorized-keys-service/authorized-keys-service";
 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
-import {
-    authActions,
-} from "~/store/auth/auth-action";
+import { authActions } from "~/store/auth/auth-action";
 
 export const SSH_KEY_CREATE_FORM_NAME = 'sshKeyCreateFormName';
 export const SSH_KEY_PUBLIC_KEY_DIALOG = 'sshKeyPublicKeyDialog';
@@ -62,7 +61,8 @@ export const removeSshKey = (uuid: string) =>
 
 export const createSshKey = (data: SshKeyCreateFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = getState().auth.user!.uuid;
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         const { name, publicKey } = data;
         dispatch(startSubmit(SSH_KEY_CREATE_FORM_NAME));
         try {
@@ -93,11 +93,10 @@ export const createSshKey = (data: SshKeyCreateFormDialogData) =>
 export const loadSshKeysPanel = () =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         try {
-            dispatch(setBreadcrumbs([{ label: 'SSH Keys'}]));
+            dispatch(setBreadcrumbs([{ label: 'SSH Keys' }]));
             const response = await services.authorizedKeysService.list();
             dispatch(authActions.SET_SSH_KEYS(response.items));
         } catch (e) {
             return;
         }
     };
-
index 926121e39a734d089b927d8915ad6e374c92038b..f7aa5c4c9f630abcddbbe1355cb90368f8005b0b 100644 (file)
@@ -2,57 +2,66 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { authReducer, AuthState } from "./auth-reducer";
-import { AuthAction, initAuth } from "./auth-action";
-import {
-    API_TOKEN_KEY,
-    USER_EMAIL_KEY,
-    USER_FIRST_NAME_KEY,
-    USER_LAST_NAME_KEY,
-    USER_OWNER_UUID_KEY,
-    USER_UUID_KEY,
-    USER_IS_ADMIN,
-    USER_IS_ACTIVE,
-    USER_USERNAME,
-    USER_PREFS
-} from "~/services/auth-service/auth-service";
+import { initAuth } from "./auth-action";
+import { API_TOKEN_KEY } from "~/services/auth-service/auth-service";
 
 import 'jest-localstorage-mock';
-import { createServices } from "~/services/services";
+import { ServiceRepository, createServices } from "~/services/services";
 import { configureStore, RootStore } from "../store";
 import createBrowserHistory from "history/createBrowserHistory";
-import { Config, mockConfig } from '~/common/config';
+import { mockConfig } from '~/common/config';
 import { ApiActions } from "~/services/api/api-actions";
-import { ACCOUNT_LINK_STATUS_KEY} from '~/services/link-account-service/link-account-service';
+import { ACCOUNT_LINK_STATUS_KEY } from '~/services/link-account-service/link-account-service';
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ImportMock } from 'ts-mock-imports';
+import * as servicesModule from "~/services/services";
 
 describe('auth-actions', () => {
-    let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
     let store: RootStore;
+    let services: ServiceRepository;
     const actions: ApiActions = {
         progressFn: (id: string, working: boolean) => { },
         errorFn: (id: string, message: string) => { }
     };
+    let importMocks: any[];
 
     beforeEach(() => {
-        store = configureStore(createBrowserHistory(), createServices(mockConfig({}), actions));
+        axiosMock.reset();
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services);
         localStorage.clear();
-        reducer = authReducer(createServices(mockConfig({}), actions));
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
     });
 
-    it('should initialise state with user and api token from local storage', () => {
+    it('should initialise state with user and api token from local storage', (done) => {
+
+        axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzzz-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            });
+
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
 
         // Only test the case when a link account operation is not being cancelled
         sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
         localStorage.setItem(API_TOKEN_KEY, "token");
-        localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
-        localStorage.setItem(USER_FIRST_NAME_KEY, "John");
-        localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
-        localStorage.setItem(USER_UUID_KEY, "zzzzz-tpzed-abcefg");
-        localStorage.setItem(USER_USERNAME, "username");
-        localStorage.setItem(USER_PREFS, JSON.stringify({}));
-        localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
-        localStorage.setItem(USER_IS_ADMIN, JSON.stringify(false));
-        localStorage.setItem(USER_IS_ACTIVE, JSON.stringify(true));
 
         const config: any = {
             rootUrl: "https://zzzzz.arvadosapi.com",
@@ -62,51 +71,84 @@ describe('auth-actions', () => {
 
         store.dispatch(initAuth(config));
 
-        expect(store.getState().auth).toEqual({
-            apiToken: "token",
-            sshKeys: [],
-            homeCluster: "zzzzz",
-            localCluster: "zzzzz",
-            remoteHostsConfig: {},
-            remoteHosts: {
-                zzzzz: "zzzzz.arvadosapi.com",
-                xc59z: "xc59z.arvadosapi.com"
-            },
-            sessions: [{
-                "active": true,
-                "baseUrl": undefined,
-                "clusterId": "zzzzz",
-                "email": "test@test.com",
-                "loggedIn": true,
-                "remoteHost": "https://zzzzz.arvadosapi.com",
-                "status": 2,
-                "token": "token",
-                "username": "John Doe"
-            }, {
-                "active": false,
-                "baseUrl": "",
-                "clusterId": "xc59z",
-                "email": "",
-                "loggedIn": false,
-                "remoteHost": "xc59z.arvadosapi.com",
-                "status": 0,
-                "token": "",
-                "username": ""
-            }],
-            user: {
-                email: "test@test.com",
-                firstName: "John",
-                lastName: "Doe",
-                uuid: "zzzzz-tpzed-abcefg",
-                ownerUuid: "ownerUuid",
-                username: "username",
-                prefs: {},
-                isAdmin: false,
-                isActive: true
+        store.subscribe(() => {
+            const auth = store.getState().auth;
+            if (auth.apiToken === "token" &&
+                auth.sessions.length === 2 &&
+                auth.sessions[0].status === 2 &&
+                auth.sessions[1].status === 2
+            ) {
+                try {
+                    expect(auth).toEqual({
+                        apiToken: "token",
+                        config: {
+                            remoteHosts: {
+                                "xc59z": "xc59z.arvadosapi.com",
+                            },
+                            rootUrl: "https://zzzzz.arvadosapi.com",
+                            uuidPrefix: "zzzzz",
+                        },
+                        sshKeys: [],
+                        homeCluster: "zzzzz",
+                        localCluster: "zzzzz",
+                        loginCluster: undefined,
+                        remoteHostsConfig: {
+                            "zzzzz": {
+                                "remoteHosts": {
+                                    "xc59z": "xc59z.arvadosapi.com",
+                                },
+                                "rootUrl": "https://zzzzz.arvadosapi.com",
+                                "uuidPrefix": "zzzzz",
+                            },
+                        },
+                        remoteHosts: {
+                            zzzzz: "zzzzz.arvadosapi.com",
+                            xc59z: "xc59z.arvadosapi.com"
+                        },
+                        sessions: [{
+                            "active": true,
+                            "baseUrl": undefined,
+                            "clusterId": "zzzzz",
+                            "email": "test@test.com",
+                            "loggedIn": true,
+                            "remoteHost": "https://zzzzz.arvadosapi.com",
+                            "status": 2,
+                            "token": "token",
+                            "name": "John Doe"
+                   "uuid": "zzzzz-tpzed-abcefg",
+                        }, {
+                            "active": false,
+                            "baseUrl": "",
+                            "clusterId": "xc59z",
+                            "email": "",
+                            "loggedIn": false,
+                            "remoteHost": "xc59z.arvadosapi.com",
+                            "status": 2,
+                            "token": "",
+                            "name": "",
+                            "uuid": "",
+                        }],
+                        user: {
+                            email: "test@test.com",
+                            firstName: "John",
+                            lastName: "Doe",
+                            uuid: "zzzzz-tpzed-abcefg",
+                            ownerUuid: "ownerUuid",
+                            username: "jdoe",
+                            prefs: { profile: {} },
+                            isAdmin: false,
+                            isActive: true
+                        }
+                    });
+                    done();
+                } catch (e) {
+                    console.log(e);
+                }
             }
         });
     });
 
+
     // TODO: Add remaining action tests
     /*
     it('should fire external url to login', () => {
index 1d1ad18cbd8c985218fc7aa433b04056d76d031d..1d8a01c6f61ef1ee6562012cc184d17a0ea732eb 100644 (file)
@@ -4,26 +4,23 @@
 
 import { ofType, unionize, UnionOf } from '~/common/unionize';
 import { Dispatch } from "redux";
-import { AxiosInstance } from "axios";
 import { RootState } from "../store";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
-import { User, UserResource } from "~/models/user";
+import { User } from "~/models/user";
 import { Session } from "~/models/session";
-import { getDiscoveryURL, Config } from '~/common/config';
-import { initSessions } from "~/store/auth/auth-action-session";
-import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
+import { Config } from '~/common/config';
 import { matchTokenRoute, matchFedTokenRoute } from '~/routes/routes';
-import Axios from "axios";
-import { AxiosError } from "axios";
+import { createServices, setAuthorizationHeader } from "~/services/services";
+import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
 
 export const authActions = unionize({
-    SAVE_API_TOKEN: ofType<string>(),
-    SAVE_USER: ofType<UserResource>(),
     LOGIN: {},
-    LOGOUT: {},
-    CONFIG: ofType<{ config: Config }>(),
-    INIT: ofType<{ user: User, token: string }>(),
+    LOGOUT: ofType<{ deleteLinkData: boolean }>(),
+    SET_CONFIG: ofType<{ config: Config }>(),
+    INIT_USER: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>(),
     SET_SSH_KEYS: ofType<SshKeyResource[]>(),
@@ -37,19 +34,6 @@ export const authActions = unionize({
     REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
 });
 
-export function setAuthorizationHeader(services: ServiceRepository, token: string) {
-    services.apiClient.defaults.headers.common = {
-        Authorization: `OAuth2 ${token}`
-    };
-    services.webdavClient.defaults.headers = {
-        Authorization: `OAuth2 ${token}`
-    };
-}
-
-function removeAuthorizationHeader(client: AxiosInstance) {
-    delete client.defaults.headers.common.Authorization;
-}
-
 export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     // Cancel any link account ops in progress unless the user has
     // just logged in or there has been a successful link operation
@@ -65,68 +49,46 @@ export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () =>
 };
 
 const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    const user = services.authService.getUser();
     const token = services.authService.getApiToken();
-    const homeCluster = services.authService.getHomeCluster();
-    if (token) {
-        setAuthorizationHeader(services, token);
+    let homeCluster = services.authService.getHomeCluster();
+    if (homeCluster && !config.remoteHosts[homeCluster]) {
+        homeCluster = undefined;
     }
-    dispatch(authActions.CONFIG({ config }));
-    dispatch(authActions.SET_HOME_CLUSTER(homeCluster || config.uuidPrefix));
-    if (token && user) {
-        dispatch(authActions.INIT({ user, token }));
-        dispatch<any>(initSessions(services.authService, config, user));
-        dispatch<any>(getUserDetails()).then((user: User) => {
-            dispatch(authActions.INIT({ user, token }));
-        }).catch((err: AxiosError) => {
-            if (err.response) {
-                // Bad token
-                if (err.response.status === 401) {
-                    logout()(dispatch, getState, services);
-                }
-            }
+    dispatch(authActions.SET_CONFIG({ config }));
+    dispatch(authActions.SET_HOME_CLUSTER(config.loginCluster || homeCluster || config.uuidPrefix));
+
+    if (token && token !== "undefined") {
+        dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+        dispatch<any>(saveApiToken(token)).then(() => {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        }).catch(() => {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
         });
     }
-    Object.keys(config.remoteHosts).map((k) => {
-        Axios.get<Config>(getDiscoveryURL(config.remoteHosts[k]))
-            .then(response => dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config: response.data })));
-    });
 };
 
-export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.saveApiToken(token);
-    setAuthorizationHeader(services, token);
-    dispatch(authActions.SAVE_API_TOKEN(token));
+export const getConfig = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Config => {
+    const state = getState().auth;
+    return state.remoteHostsConfig[state.localCluster];
 };
 
-export const saveUser = (user: UserResource) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.saveUser(user);
-    dispatch(authActions.SAVE_USER(user));
+export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+    const config = dispatch<any>(getConfig);
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, token);
+    return svc.authService.getUserDetails().then((user: User) => {
+        dispatch(authActions.INIT_USER({ user, token }));
+    });
 };
 
-export const login = (uuidPrefix: string, homeCluster: string, remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.login(uuidPrefix, homeCluster, remoteHosts);
-    dispatch(authActions.LOGIN());
-};
+export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
+    remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
+        dispatch(authActions.LOGIN());
+    };
 
 export const logout = (deleteLinkData: boolean = false) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    if (deleteLinkData) {
-        services.linkAccountService.removeAccountToLink();
-    }
-    services.authService.removeApiToken();
-    services.authService.removeUser();
-    removeAuthorizationHeader(services.apiClient);
-    services.authService.logout();
-    dispatch(authActions.LOGOUT());
-};
-
-export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<User> => {
-    dispatch(authActions.USER_DETAILS_REQUEST());
-    return services.authService.getUserDetails().then(user => {
-        services.authService.saveUser(user);
-        dispatch(authActions.USER_DETAILS_SUCCESS(user));
-        return user;
-    });
+    dispatch(authActions.LOGOUT({ deleteLinkData }));
 };
 
 export type AuthAction = UnionOf<typeof authActions>;
diff --git a/src/store/auth/auth-middleware.ts b/src/store/auth/auth-middleware.ts
new file mode 100644 (file)
index 0000000..76f8598
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Middleware } from "redux";
+import { authActions, } from "./auth-action";
+import { ServiceRepository, setAuthorizationHeader, removeAuthorizationHeader } from "~/services/services";
+import { initSessions } from "~/store/auth/auth-action-session";
+import { User } from "~/models/user";
+import { RootState } from '~/store/store';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
+
+export const authMiddleware = (services: ServiceRepository): Middleware => store => next => action => {
+    // Middleware to update external state (local storage, window
+    // title) to ensure that they stay in sync with redux state.
+
+    authActions.match(action, {
+        INIT_USER: ({ user, token }) => {
+            // The "next" method passes the action to the next
+            // middleware in the chain, or the reducer.  That means
+            // after next() returns, the action has (presumably) been
+            // applied by the reducer to update the state.
+            next(action);
+
+            const state: RootState = store.getState();
+
+            if (state.auth.apiToken) {
+                services.authService.saveApiToken(state.auth.apiToken);
+                setAuthorizationHeader(services, state.auth.apiToken);
+            } else {
+                services.authService.removeApiToken();
+                removeAuthorizationHeader(services);
+            }
+
+            store.dispatch<any>(initSessions(services.authService, state.auth.remoteHostsConfig[state.auth.localCluster], user));
+            if (!user.isActive) {
+                // As a special case, if the user is inactive, they
+                // may be able to self-activate using the "activate"
+                // method.  Note, for this to work there can't be any
+                // unsigned user agreements, we assume the API server is just going to
+                // rubber-stamp our activation request.  At some point in the future we'll
+                // want to either add support for displaying/signing user
+                // agreements or get rid of self-activation.
+                // For more details, see:
+                // https://doc.arvados.org/master/admin/user-management.html
+
+                store.dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+                services.userService.activate(user.uuid).then((user: User) => {
+                    store.dispatch(authActions.INIT_USER({ user, token }));
+                    store.dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+                }).catch(() => {
+                    store.dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+                });
+            }
+        },
+        SET_CONFIG: ({ config }) => {
+            document.title = `Arvados Workbench (${config.uuidPrefix})`;
+            next(action);
+        },
+        LOGOUT: ({ deleteLinkData }) => {
+            next(action);
+            if (deleteLinkData) {
+                services.linkAccountService.removeAccountToLink();
+            }
+            services.authService.removeApiToken();
+            services.authService.removeUser();
+            removeAuthorizationHeader(services);
+            services.authService.logout();
+        },
+        default: () => next(action)
+    });
+};
index 14d92803cc1fc21750d662d723ded60c2d3221ad..756feeeb6b835d7c3c845c88752a0aa6726dd6db 100644 (file)
@@ -35,30 +35,16 @@ describe('auth-reducer', () => {
             isAdmin: false,
             isActive: true
         };
-        const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
+        const state = reducer(initialState, authActions.INIT_USER({ user, token: "token" }));
         expect(state).toEqual({
             apiToken: "token",
+            config: mockConfig({}),
             user,
             sshKeys: [],
             sessions: [],
             homeCluster: "zzzzz",
             localCluster: "",
-            remoteHosts: {},
-            remoteHostsConfig: {}
-        });
-    });
-
-    it('should save api token', () => {
-        const initialState = undefined;
-
-        const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
-        expect(state).toEqual({
-            apiToken: "token",
-            user: undefined,
-            sshKeys: [],
-            sessions: [],
-            homeCluster: "",
-            localCluster: "",
+            loginCluster: "",
             remoteHosts: {},
             remoteHostsConfig: {}
         });
@@ -82,10 +68,12 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
         expect(state).toEqual({
             apiToken: undefined,
+            config: mockConfig({}),
             sshKeys: [],
             sessions: [],
-            homeCluster: "",
+            homeCluster: "uuid",
             localCluster: "",
+            loginCluster: "",
             remoteHosts: {},
             remoteHostsConfig: {},
             user: {
index cded9f0e71816636ce1876d51c83d3976abb589c..946407fe24172610fbc3aaf9cff7b95052a43af8 100644 (file)
@@ -3,11 +3,11 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { authActions, AuthAction } from "./auth-action";
-import { User, UserResource } from "~/models/user";
+import { User } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
 import { Session } from "~/models/session";
-import { Config } from '~/common/config';
+import { Config, mockConfig } from '~/common/config';
 
 export interface AuthState {
     user?: User;
@@ -16,8 +16,10 @@ export interface AuthState {
     sessions: Session[];
     localCluster: string;
     homeCluster: string;
+    loginCluster: string;
     remoteHosts: { [key: string]: string };
     remoteHostsConfig: { [key: string]: Config };
+    config: Config;
 }
 
 const initialState: AuthState = {
@@ -27,24 +29,23 @@ const initialState: AuthState = {
     sessions: [],
     localCluster: "",
     homeCluster: "",
+    loginCluster: "",
     remoteHosts: {},
-    remoteHostsConfig: {}
+    remoteHostsConfig: {},
+    config: mockConfig({})
 };
 
 export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
     return authActions.match(action, {
-        SAVE_API_TOKEN: (token: string) => {
-            return { ...state, apiToken: token };
-        },
-        SAVE_USER: (user: UserResource) => {
-            return { ...state, user};
-        },
-        CONFIG: ({ config }) => {
+        SET_CONFIG: ({ config }) => {
             return {
                 ...state,
+                config,
                 localCluster: config.uuidPrefix,
                 remoteHosts: { ...config.remoteHosts, [config.uuidPrefix]: new URL(config.rootUrl).host },
-                homeCluster: config.uuidPrefix
+                homeCluster: config.loginCluster || config.uuidPrefix,
+                loginCluster: config.loginCluster,
+                remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config }
             };
         },
         REMOTE_CLUSTER_CONFIG: ({ config }) => {
@@ -53,7 +54,7 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat
                 remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
             };
         },
-        INIT: ({ user, token }) => {
+        INIT_USER: ({ user, token }) => {
             return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
         },
         LOGIN: () => {
@@ -63,7 +64,7 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat
             return { ...state, apiToken: undefined };
         },
         USER_DETAILS_SUCCESS: (user: User) => {
-            return { ...state, user };
+            return { ...state, user, homeCluster: user.uuid.substr(0, 5) };
         },
         SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
             return { ...state, sshKeys };
index 04b5689c5e7aaaf7d24c7ee5e165ad3b4150b630..90af2c2fb42c55cda81fad596cef9d857d76f213 100644 (file)
@@ -4,6 +4,7 @@
 
 import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs';
 import { getResource } from '~/store/resources/resources';
 import { TreePicker } from '../tree-picker/tree-picker';
@@ -47,7 +48,7 @@ export const setSidePanelBreadcrumbs = (uuid: string) =>
         const path = getState().router.location!.pathname;
         const currentUuid = path.split('/')[2];
         const uuidKind = extractUuidKind(currentUuid);
-        
+
         if (uuidKind === ResourceKind.COLLECTION) {
             const collectionItem = await services.collectionService.get(currentUuid);
             dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
@@ -92,7 +93,7 @@ export const setCategoryBreadcrumbs = (uuid: string, category: SidePanelTreeCate
 export const setProjectBreadcrumbs = (uuid: string) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const ancestors = getSidePanelTreeNodeAncestorsIds(uuid)(getState().treePicker);
-        const rootUuid = services.authService.getUuid();
+        const rootUuid = getUserUuid(getState());
         if (uuid === rootUuid || ancestors.find(uuid => uuid === rootUuid)) {
             dispatch(setSidePanelBreadcrumbs(uuid));
         } else {
index b1dd8389611611940f2c270889dbaf5663bb3a25..540b8c6a011b6ab80b2163b52000db20a050b6af 100644 (file)
@@ -45,16 +45,22 @@ export const createCollectionTag = (data: TagProperty) =>
         const uuid = item ? item.uuid : '';
         try {
             if (item) {
-                item.properties[data.key] = data.value;
-                const version = 'version';
-                delete item[version];
-                const updatedCollection = await services.collectionService.update(uuid, item);
+                const updatedCollection = await services.collectionService.update(
+                    uuid, {
+                        properties: {
+                            ...JSON.parse(JSON.stringify(item.properties)),
+                            [data.keyID || data.key]: data.valueID || data.value
+                        }
+                    }
+                );
+                item.properties = updatedCollection.properties;
                 dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
                 return updatedCollection;
             }
             return;
         } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
             return;
         }
     };
@@ -65,7 +71,7 @@ export const navigateToProcess = (uuid: string) =>
             await services.containerRequestService.get(uuid);
             dispatch<any>(navigateTo(uuid));
         } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This process does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This process does not exist!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
@@ -76,13 +82,18 @@ export const deleteCollectionTag = (key: string) =>
         try {
             if (item) {
                 delete item.properties[key];
-                const updatedCollection = await services.collectionService.update(uuid, item);
+                const updatedCollection = await services.collectionService.update(
+                    uuid, {
+                        properties: {...item.properties}
+                    }
+                );
                 dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
                 return updatedCollection;
             }
             return;
         } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
             return;
         }
     };
index 534d70d480e2fcf263db814a1e46b539619ababd..9d3ae86165ed5d786b00a7e12aae3319d9f4d288 100644 (file)
@@ -4,17 +4,16 @@
 
 import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { Dispatch } from "redux";
-import { CollectionFilesTree, CollectionFileType } from "~/models/collection-file";
+import { CollectionFilesTree, CollectionFileType, createCollectionFilesTree } from "~/models/collection-file";
 import { ServiceRepository } from "~/services/services";
 import { RootState } from "../../store";
 import { snackbarActions, SnackbarKind } from "../../snackbar/snackbar-actions";
 import { dialogActions } from '../../dialog/dialog-actions';
-import { getNodeValue } from "~/models/tree";
+import { getNodeValue, mapTreeValues } from "~/models/tree";
 import { filterCollectionFilesBySelection } from './collection-panel-files-state';
-import { startSubmit, stopSubmit, reset, initialize, FormErrors } from 'redux-form';
+import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
 import { getDialog } from "~/store/dialog/dialog-reducer";
-import { getFileFullPath } from "~/services/collection-service/collection-service-files-response";
-import { resourcesDataActions } from "~/store/resources-data/resources-data-actions";
+import { getFileFullPath, sortFilesTree } from "~/services/collection-service/collection-service-files-response";
 
 export const collectionPanelFilesAction = unionize({
     SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
@@ -29,8 +28,13 @@ export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesActi
 export const loadCollectionFiles = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const files = await services.collectionService.files(uuid);
-        dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(files));
-        dispatch(resourcesDataActions.SET_FILES({ uuid, files }));
+
+        // Given the array of directories and files, create the appropriate tree nodes,
+        // sort them, and add the complete url to each.
+        const tree = createCollectionFilesTree(files);
+        const sorted = sortFilesTree(tree);
+        const mapped = mapTreeValues(services.collectionService.extendFileURL)(sorted);
+        dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(mapped));
     };
 
 export const removeCollectionFiles = (filePaths: string[]) =>
index 642e7b82666c176de9d805de927dc7a6846275cc..57d09bf4be1c90f552a1f9c592b84ba5e5bdf8c9 100644 (file)
@@ -6,6 +6,7 @@ import { ServiceRepository } from '~/services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
 import { DataExplorerMiddlewareService } from '~/store/data-explorer/data-explorer-middleware-service';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { getDataExplorer } from '~/store/data-explorer/data-explorer-reducer';
 import { resourcesActions } from '~/store/resources/resources-actions';
@@ -48,7 +49,7 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
             }
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
-                const userUuid = api.getState().auth.user!.uuid;
+                const userUuid = getUserUuid(api.getState());
                 const pathname = api.getState().router.location!.pathname;
                 const contentAddress = pathname.split('/')[2];
                 const response = await this.services.collectionService.list({
@@ -88,11 +89,11 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
                         .addIn('uuid', groupUuids)
                         .getFilters()
                 });
-                responseUsers.items.map(it=>{
-                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({name: it.uuid === userUuid ? 'User: Me' : `User: ${it.firstName} ${it.lastName}`, uuid: it.uuid}));
+                responseUsers.items.map(it => {
+                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({ name: it.uuid === userUuid ? 'User: Me' : `User: ${it.firstName} ${it.lastName}`, uuid: it.uuid }));
                 });
-                responseGroups.items.map(it=>{
-                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({name: `Project: ${it.name}`, uuid: it.uuid}));
+                responseGroups.items.map(it => {
+                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({ name: `Project: ${it.name}`, uuid: it.uuid }));
                 });
                 api.dispatch<any>(setBreadcrumbs([{ label: 'Projects', uuid: userUuid }]));
                 api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
@@ -134,4 +135,4 @@ const couldNotFetchCollections = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch collection with this content address.',
         kind: SnackbarKind.ERROR
-    });
\ No newline at end of file
+    });
index 0ce92dfabcecbdc86a3c4cd087b97d4bcee2fddd..b13d08aaad236eb8eae6962a969fc1aaae79e326 100644 (file)
@@ -38,7 +38,7 @@ export const copyCollection = (resource: CopyFormDialogData) =>
             return newCollection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(
                     COLLECTION_COPY_FORM_NAME,
                     { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors
index 8d1e9ba5f247be426fc0b2faabb6d4ec60f858fb..83b3e89c2b162e3f718f14005ea556c19eaf973c 100644 (file)
@@ -5,6 +5,7 @@
 import { Dispatch } from "redux";
 import { reset, startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { ServiceRepository } from '~/services/services';
 import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
@@ -28,7 +29,8 @@ export const openCollectionCreateDialog = (ownerUuid: string) =>
         const router = getState();
         const properties = getState().properties;
         if (isItemNotInProject(properties) || !isProjectOrRunProcessRoute(router)) {
-            const userUuid = getState().auth.user!.uuid;
+            const userUuid = getUserUuid(getState());
+            if (!userUuid) { return; }
             dispatch(initialize(COLLECTION_CREATE_FORM_NAME, { userUuid }));
         } else {
             dispatch(initialize(COLLECTION_CREATE_FORM_NAME, { ownerUuid }));
@@ -47,11 +49,10 @@ export const createCollection = (data: CollectionCreateFormDialogData) =>
             await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
             dispatch(reset(COLLECTION_CREATE_FORM_NAME));
-            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
             return newCollection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
             } else if (error === CommonResourceServiceError.NONE) {
                 dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME));
@@ -63,7 +64,8 @@ export const createCollection = (data: CollectionCreateFormDialogData) =>
                 }));
                 await services.collectionService.delete(newCollection!.uuid);
             }
-            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
             return;
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
         }
     };
index dc73e5a515bdc9d07d34b1361ba656d98380ab6c..6ccd0caaf8dba1afeaa6af58358cfd7a5dddb181 100644 (file)
@@ -30,15 +30,15 @@ export const moveCollection = (resource: MoveToFormDialogData) =>
         dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
         try {
             dispatch(progressIndicatorActions.START_WORKING(COLLECTION_MOVE_FORM_NAME));
+            await services.collectionService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
             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(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
             return collection;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_MOVE_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
index 65561aa48a33e4bd6f28860332a08e81b9011b86..72374e65970aae31c50a36387d2788107ec30222 100644 (file)
@@ -72,7 +72,7 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec
                 dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
             } catch (e) {
                 const error = getCommonResourceServiceError(e);
-                if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                     dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME, { name: 'Collection with this name already exists.' } as FormErrors));
                 } else if (error === CommonResourceServiceError.UNKNOWN) {
                     dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
index 02ec8bb5824920879691a7ae7935ac64263ab30e..5b176beac5bd6d2c67bcbca2204e70b307e1c369 100644 (file)
@@ -31,19 +31,23 @@ export const updateCollection = (collection: Partial<CollectionResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const uuid = collection.uuid || '';
         dispatch(startSubmit(COLLECTION_UPDATE_FORM_NAME));
+        dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPDATE_FORM_NAME));
         try {
-            dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPDATE_FORM_NAME));
             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 }));
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
             return updatedCollection;
         } catch (e) {
+            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
+            } else {
+                // Unknown error, handling left to caller.
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+                throw(e);
             }
-            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
-            return;
         }
+        return;
     };
diff --git a/src/store/config/config-action.ts b/src/store/config/config-action.ts
deleted file mode 100644 (file)
index fd79294..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { ofType, unionize, UnionOf } from '~/common/unionize';
-import { Config } from '~/common/config';
-
-export const configActions = unionize({
-    CONFIG: ofType<{ config: Config }>(),
-});
-
-export type ConfigAction = UnionOf<typeof configActions>;
diff --git a/src/store/config/config-reducer.ts b/src/store/config/config-reducer.ts
deleted file mode 100644 (file)
index f0b76b1..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { configActions, ConfigAction } from "./config-action";
-import { mockConfig } from '~/common/config';
-
-export const configReducer = (state = mockConfig({}), action: ConfigAction) => {
-    return configActions.match(action, {
-        CONFIG: ({ config }) => {
-            return {
-                ...state, ...config
-            };
-        },
-        default: () => state
-    });
-};
index 52ea0e785ebb1c15b32ff404fdf19dcb3aaa9f76..6874671432ff8b31bd8bdf77092e753262b000b3 100644 (file)
@@ -43,7 +43,7 @@ export const deleteProjectProperty = (key: string) =>
         try {
             if (project) {
                 delete project.properties[key];
-                const updatedProject = await services.projectService.update(project.uuid, project);
+                const updatedProject = await services.projectService.update(project.uuid, { properties: project.properties });
                 dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
             }
@@ -60,8 +60,14 @@ export const createProjectProperty = (data: TagProperty) =>
         dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
         try {
             if (project) {
-                project.properties[data.key] = data.value;
-                const updatedProject = await services.projectService.update(project.uuid, project);
+                const updatedProject = await services.projectService.update(
+                    project.uuid, {
+                        properties: {
+                            ...JSON.parse(JSON.stringify(project.properties)),
+                            [data.keyID || data.key]: data.valueID || data.value
+                        }
+                    }
+                );
                 dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
                 dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
index 868d7b05753d0f0b7d9dc0dc006a1f1dd70a639a..b242366d648c6e3af9a6a57d2831d5752f8be0a2 100644 (file)
@@ -5,6 +5,7 @@
 import { DataExplorerMiddlewareService } from "~/store/data-explorer/data-explorer-middleware-service";
 import { FavoritePanelColumnNames } from "~/views/favorite-panel/favorite-panel";
 import { RootState } from "../store";
+import { getUserUuid } from "~/common/getuser";
 import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
 import { SortDirection } from "~/components/data-table/data-column";
@@ -59,7 +60,7 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
                 const responseLinks = await this.services.linkService.list({
                     filters: new FilterBuilder()
                         .addEqual("linkClass", 'star')
-                        .addEqual('tailUuid', this.services.authService.getUuid()!)
+                        .addEqual('tailUuid', getUserUuid(api.getState()))
                         .addEqual('tailKind', ResourceKind.USER)
                         .getFilters()
                 }).then(results => results);
@@ -94,7 +95,7 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
                     response.itemsAvailable++;
                     response.items.push(it);
                 });
-                
+
                 api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
                 api.dispatch(resourcesActions.SET_RESOURCES(response.items));
                 await api.dispatch<any>(loadMissingProcessesInformation(response.items));
index dfa2740ecfdc836f9ce396f950399c7c80622373..3a16c562423122f58830770cc9fd4d30ea0e83b1 100644 (file)
@@ -5,6 +5,7 @@
 import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { Dispatch } from "redux";
 import { RootState } from "../store";
+import { getUserUuid } from "~/common/getuser";
 import { checkFavorite } from "./favorites-reducer";
 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
 import { ServiceRepository } from "~/services/services";
@@ -20,8 +21,11 @@ export type FavoritesAction = UnionOf<typeof favoritesActions>;
 
 export const toggleFavorite = (resource: { uuid: string; name: string }) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) {
+            return Promise.reject("No user");
+        }
         dispatch(progressIndicatorActions.START_WORKING("toggleFavorite"));
-        const userUuid = getState().auth.user!.uuid;
         dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
         const isFavorite = checkFavorite(resource.uuid, getState().favorites);
         dispatch(snackbarActions.OPEN_SNACKBAR({
@@ -56,7 +60,8 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) =>
 
 export const updateFavorites = (resourceUuids: string[]) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = getState().auth.user!.uuid;
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids));
         services.favoriteService
             .checkPresenceInFavorites(userUuid, resourceUuids)
index b73c3bbea9399fb639b5117f45b916f1aa3511b9..55bfd5ae8d1cef4e22079923e6e156649c370675 100644 (file)
@@ -15,7 +15,6 @@ import { GroupResource } from '~/models/group';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { PermissionResource } from '~/models/permission';
-import { GroupDetailsPanel } from '~/views/group-details-panel/group-details-panel';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { UserResource, getUserFullname } from '~/models/user';
 
index b7cf48054531c3fb988ab19cc5071660e6fe258c..cfd8438cbde246b45b79c89005b908770210f3e1 100644 (file)
@@ -13,7 +13,7 @@ import { getResource } from '~/store/resources/resources';
 import { GroupResource } from '~/models/group';
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { PermissionLevel, PermissionResource } from '~/models/permission';
+import { PermissionLevel } from '~/models/permission';
 import { PermissionService } from '~/services/permission-service/permission-service';
 import { FilterBuilder } from '~/services/api/filter-builder';
 
@@ -101,7 +101,7 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
         } catch (e) {
 
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(CREATE_GROUP_FORM, { name: 'Group with the same name already exists.' } as FormErrors));
             }
 
@@ -118,9 +118,9 @@ interface AddGroupMemberArgs {
 }
 
 /**
- * Group membership is determined by whether the group has can_read permission on an object. 
+ * Group membership is determined by whether the group has can_read permission on an object.
  * If a group G can_read an object A, then we say A is a member of G.
- * 
+ *
  * [Permission model docs](https://doc.arvados.org/api/permission-model.html)
  */
 export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
index 7a5d93cc6e4ba1502f1efd01bd61bfa332f40196..b5ef51b765418904aad2d356deb47daf6dfea590 100644 (file)
@@ -7,7 +7,7 @@ import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta, data
 import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { getDataExplorer, DataExplorer, getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
+import { getDataExplorer, getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
 import { GroupsPanelActions } from '~/store/groups-panel/groups-panel-actions';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { updateResources } from '~/store/resources/resources-actions';
index cdc99660b7a3be268c66ee79351fa99ed75208e3..1e94fcfacaf46d44469a72b4f616c54fb432e541 100644 (file)
@@ -4,37 +4,43 @@
 
 import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
+import { getUserUuid } from "~/common/getuser";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "~/services/services";
 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import { LinkAccountType, AccountToLink, LinkAccountStatus } from "~/models/link-account";
-import { saveApiToken, saveUser } from "~/store/auth/auth-action";
+import { authActions, getConfig } from "~/store/auth/auth-action";
 import { unionize, ofType, UnionOf } from '~/common/unionize';
 import { UserResource } from "~/models/user";
 import { GroupResource } from "~/models/group";
 import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
-import { login, logout, setAuthorizationHeader } from "~/store/auth/auth-action";
+import { login, logout } from "~/store/auth/auth-action";
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
 
 export const linkAccountPanelActions = unionize({
     LINK_INIT: ofType<{
-        targetUser: UserResource | undefined }>(),
+        targetUser: UserResource | undefined
+    }>(),
     LINK_LOAD: ofType<{
         originatingUser: OriginatingUser | undefined,
         targetUser: UserResource | undefined,
         targetUserToken: string | undefined,
         userToLink: UserResource | undefined,
-        userToLinkToken: string | undefined }>(),
+        userToLinkToken: string | undefined
+    }>(),
     LINK_INVALID: ofType<{
         originatingUser: OriginatingUser | undefined,
         targetUser: UserResource | undefined,
         userToLink: UserResource | undefined,
-        error: LinkAccountPanelError }>(),
+        error: LinkAccountPanelError
+    }>(),
     SET_SELECTED_CLUSTER: ofType<{
-        selectedCluster: string }>(),
+        selectedCluster: string
+    }>(),
     SET_IS_PROCESSING: ofType<{
-        isProcessing: boolean}>(),
+        isProcessing: boolean
+    }>(),
     HAS_SESSION_DATA: {}
 });
 
@@ -53,6 +59,13 @@ function validateLink(userToLink: UserResource, targetUser: UserResource) {
     return LinkAccountPanelError.NONE;
 }
 
+const newServices = (dispatch: Dispatch<any>, token: string) => {
+    const config = dispatch<any>(getConfig);
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, token);
+    return svc;
+};
+
 export const checkForLinkStatus = () =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const status = services.linkAccountService.getLinkOpStatus();
@@ -78,8 +91,7 @@ export const checkForLinkStatus = () =>
 
 export const switchUser = (user: UserResource, token: string) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(saveUser(user));
-        dispatch(saveApiToken(token));
+        dispatch(authActions.INIT_USER({ user, token }));
     };
 
 export const linkFailed = () =>
@@ -119,7 +131,7 @@ export const loadLinkAccountPanel = () =>
             dispatch(checkForLinkStatus());
 
             // Continue loading the link account panel
-            dispatch(setBreadcrumbs([{ label: 'Link account'}]));
+            dispatch(setBreadcrumbs([{ label: 'Link account' }]));
             const curUser = getState().auth.user;
             const curToken = getState().auth.apiToken;
             if (curUser && curToken) {
@@ -133,9 +145,8 @@ export const loadLinkAccountPanel = () =>
 
                     // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
                     // issues since a user will always be able to query the api server for their own user data.
-                    setAuthorizationHeader(services, linkAccountData.token);
-                    const savedUserResource = await services.userService.get(linkAccountData.userUuid);
-                    setAuthorizationHeader(services, curToken);
+                    const svc = newServices(dispatch, linkAccountData.token);
+                    const savedUserResource = await svc.userService.get(linkAccountData.userUuid);
 
                     let params: any;
                     if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
@@ -170,7 +181,8 @@ export const loadLinkAccountPanel = () =>
                             originatingUser: params.originatingUser,
                             targetUser: params.targetUser,
                             userToLink: params.userToLink,
-                            error}));
+                            error
+                        }));
                         return;
                     }
                 }
@@ -192,18 +204,20 @@ export const loadLinkAccountPanel = () =>
 
 export const startLinking = (t: LinkAccountType) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const accountToLink = {type: t, userUuid: services.authService.getUuid(), token: services.authService.getApiToken()} as AccountToLink;
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const accountToLink = { type: t, userUuid, token: services.authService.getApiToken() } as AccountToLink;
         services.linkAccountService.saveAccountToLink(accountToLink);
 
         const auth = getState().auth;
-        const isLocalUser = auth.user!.uuid.substring(0,5) === auth.localCluster;
+        const isLocalUser = auth.user!.uuid.substring(0, 5) === auth.localCluster;
         let homeCluster = auth.localCluster;
         if (isLocalUser && t === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
             homeCluster = getState().linkAccountPanel.selectedCluster!;
         }
 
         dispatch(logout());
-        dispatch(login(auth.localCluster, homeCluster, auth.remoteHosts));
+        dispatch(login(auth.localCluster, homeCluster, auth.loginCluster, auth.remoteHosts));
     };
 
 export const getAccountLinkData = () =>
@@ -220,8 +234,8 @@ export const cancelLinking = (reload: boolean = false) =>
             const linkAccountData = services.linkAccountService.getAccountToLink();
             if (linkAccountData) {
                 services.linkAccountService.removeAccountToLink();
-                setAuthorizationHeader(services, linkAccountData.token);
-                user = await services.userService.get(linkAccountData.userUuid);
+                const svc = newServices(dispatch, linkAccountData.token);
+                user = await svc.userService.get(linkAccountData.userUuid);
                 dispatch(switchUser(user, linkAccountData.token));
                 services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.CANCELLED);
             }
@@ -258,18 +272,18 @@ export const linkAccount = () =>
             try {
                 // The merge api links the user sending the request into the user
                 // specified in the request, so change the authorization header accordingly
-                setAuthorizationHeader(services, linkState.userToLinkToken);
-                await services.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
+                const svc = newServices(dispatch, linkState.userToLinkToken);
+                await svc.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
                 dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
                 services.linkAccountService.removeAccountToLink();
                 services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.SUCCESS);
                 location.reload();
             }
-            catch(e) {
+            catch (e) {
                 // If the link operation fails, delete the previously made project
                 try {
-                    setAuthorizationHeader(services, linkState.targetUserToken);
-                    await services.projectService.delete(newGroup.uuid);
+                    const svc = newServices(dispatch, linkState.targetUserToken);
+                    await svc.projectService.delete(newGroup.uuid);
                 }
                 finally {
                     dispatch(linkFailed());
@@ -277,4 +291,4 @@ export const linkAccount = () =>
                 throw e;
             }
         }
-    };
\ No newline at end of file
+    };
index d1bd8dfd62ddd03b7c6d099e9c3d33b50e07b75b..15a9f18903993d40dfc58bbc60a19a4dfaa0e32a 100644 (file)
@@ -4,7 +4,6 @@
 
 import { linkAccountPanelReducer, LinkAccountPanelError, LinkAccountPanelStatus, OriginatingUser } from "~/store/link-account-panel/link-account-panel-reducer";
 import { linkAccountPanelActions } from "~/store/link-account-panel/link-account-panel-actions";
-import { UserResource } from "~/models/user";
 
 describe('link-account-panel-reducer', () => {
     const initialState = undefined;
index 34bb2693dbb7b55796767cce66efdd93a5b93403..d36430a23228ec940b2fe57c62509ea023889981 100644 (file)
@@ -14,18 +14,17 @@ export const MY_ACCOUNT_FORM = 'myAccountForm';
 
 export const loadMyAccountPanel = () =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(setBreadcrumbs([{ label: 'User profile'}]));
+        dispatch(setBreadcrumbs([{ label: 'User profile' }]));
     };
 
 export const saveEditedUser = (resource: any) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         try {
             await services.userService.update(resource.uuid, resource);
-            services.authService.saveUser(resource);
             dispatch(authActions.USER_DETAILS_SUCCESS(resource));
             dispatch(initialize(MY_ACCOUNT_FORM, resource));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-        } catch(e) {
+        } catch (e) {
             return;
         }
     };
index d93e9ab0a08da07096168d84bc8cbbea9d1c0121..5ece1abaa1488cb989a2a9e306624dfa8c693206 100644 (file)
@@ -3,12 +3,10 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch, compose, AnyAction } from 'redux';
-import { push, RouterAction } from "react-router-redux";
+import { push } from "react-router-redux";
 import { ResourceKind, extractUuidKind } from '~/models/resource';
-import { getCollectionUrl } from "~/models/collection";
-import { getProjectUrl } from "~/models/project";
 import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
-import { Routes, getProcessUrl, getProcessLogUrl, getGroupUrl, getNavUrl } from '~/routes/routes';
+import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from '~/routes/routes';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions';
@@ -61,9 +59,9 @@ export const pushOrGoto = (url: string): AnyAction => {
 export const navigateToProcessLogs = compose(push, getProcessLogUrl);
 
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    const rootProjectUuid = services.authService.getUuid();
-    if (rootProjectUuid) {
-        dispatch<any>(navigateTo(rootProjectUuid));
+    const usr = getState().auth.user;
+    if (usr) {
+        dispatch<any>(navigateTo(usr.uuid));
     }
 };
 
@@ -71,7 +69,13 @@ export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
 
 export const navigateToRunProcess = push(Routes.RUN_PROCESS);
 
-export const navigateToSearchResults = push(Routes.SEARCH_RESULTS);
+export const navigateToSearchResults = (searchValue: string) => {
+    if (searchValue !== "") {
+        return push({ pathname: Routes.SEARCH_RESULTS, search: '?q=' + encodeURIComponent(searchValue) });
+    } else {
+        return push({ pathname: Routes.SEARCH_RESULTS });
+    }
+};
 
 export const navigateToUserVirtualMachines = push(Routes.VIRTUAL_MACHINES_USER);
 
index 7ce2749c9251db0a5242784303d11db6128d848b..7e22b53f1496275b470fdeca5aea63d350fafa40 100644 (file)
@@ -5,8 +5,11 @@
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { RootState } from '~/store/store';
 import { Dispatch } from 'redux';
-import { getProcess } from '~/store/processes/process';
+import { getProcess, Process } from '~/store/processes/process';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getWorkflowInputs } from '~/models/workflow';
+import { JSONMount } from '~/models/mount-types';
+import { MOUNT_PATH_CWL_WORKFLOW } from '~/models/process';
 
 export const PROCESS_INPUT_DIALOG_NAME = 'processInputDialog';
 
@@ -15,10 +18,17 @@ export const openProcessInputDialog = (processUuid: string) =>
         const process = getProcess(processUuid)(getState().resources);
         if (process) {
             const data: any = process;
-            if (data && data.containerRequest.mounts.varLibCwlWorkflowJson && data.containerRequest.mounts.varLibCwlWorkflowJson.content.graph.filter((a: any) => a.class === 'Workflow')[0] && data.containerRequest.mounts.varLibCwlWorkflowJson.content.graph.filter((a: any) => a.class === 'Workflow')[0].inputs.length > 0) {
+            const inputs = getInputsFromWFMount(process);
+            if (inputs && inputs.length > 0) {
                 dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_INPUT_DIALOG_NAME, data }));
             } else {
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'There are no inputs in this process!', kind: SnackbarKind.ERROR }));
             }
         }
-    }; 
\ No newline at end of file
+    };
+
+const getInputsFromWFMount = (process: Process) => {
+    if (!process || !process.containerRequest[MOUNT_PATH_CWL_WORKFLOW] ) { return undefined; }
+    const mnt = process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW] as JSONMount;
+    return getWorkflowInputs(mnt.content);
+};
\ No newline at end of file
index 475b4c1fb80c393aa43f70321de579244463a051..5ad41fd5f7f42d9cde5ea4fd57330494c5d0970a 100644 (file)
@@ -41,7 +41,7 @@ export const moveProcess = (resource: MoveToFormDialogData) =>
             return process;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: 'A process with the same name already exists in the target project.' } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
index f8d78bc60a0a94930afa8689bf059b85f055b92f..22008d1453c6ee4ba1ed4aa3ddecf2678128e65c 100644 (file)
@@ -41,7 +41,7 @@ export const updateProcess = (resource: ProcessUpdateFormDialogData) =>
             return updatedProcess;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: 'Process with the same name already exists.' } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
index 6f1d23b0b2e9258538e9d4680f81805c26589adf..47d6824a3075ad153bee90de36b63f25adead642 100644 (file)
@@ -15,10 +15,11 @@ import { projectPanelActions } from '~/store/project-panel/project-panel-action'
 import { navigateToRunProcess } from '~/store/navigation/navigation-action';
 import { goToStep, runProcessPanelActions } from '~/store/run-process-panel/run-process-panel-actions';
 import { getResource } from '~/store/resources/resources';
-import { getInputValue } from "~/views-components/process-input-dialog/process-input-dialog";
 import { initialize } from "redux-form";
 import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "~/views/run-process-panel/run-process-basic-form";
 import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "~/views/run-process-panel/run-process-advanced-form";
+import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from '~/models/process';
+import { getWorkflow, getWorkflowInputs } from "~/models/workflow";
 
 export const loadProcess = (containerRequestUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process> => {
@@ -85,9 +86,9 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         const workflows = getState().runProcessPanel.searchWorkflows;
         const workflow = workflows.find(workflow => workflow.uuid === workflowUuid);
         if (workflow && process) {
-            const newValues = getInputs(process);
-            process.mounts.varLibCwlWorkflowJson.content.graph[1].inputs = newValues;
-            const stringifiedDefinition = JSON.stringify(process.mounts.varLibCwlWorkflowJson.content);
+            const mainWf = getWorkflow(process.mounts[MOUNT_PATH_CWL_WORKFLOW]);
+            if (mainWf) { mainWf.inputs = getInputs(process); }
+            const stringifiedDefinition = JSON.stringify(process.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
             const newWorkflow = { ...workflow, definition: stringifiedDefinition };
 
             const basicInitialData: RunProcessBasicFormData = { name: `Copy of: ${process.name}`, description: process.description };
@@ -95,10 +96,10 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
 
             const advancedInitialData: RunProcessAdvancedFormData = {
                 output: process.outputName,
-                runtime: process.schedulingParameters.maxRunTime,
+                runtime: process.schedulingParameters.max_run_time,
                 ram: process.runtimeConstraints.ram,
                 vcpus: process.runtimeConstraints.vcpus,
-                keepCacheRam: process.runtimeConstraints.keepCacheRam,
+                keep_cache_ram: process.runtimeConstraints.keep_cache_ram,
                 api: process.runtimeConstraints.API
             };
             dispatch<any>(initialize(RUN_PROCESS_ADVANCED_FORM, advancedInitialData));
@@ -112,10 +113,21 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         }
     };
 
-const getInputs = (data: any) =>
-    data && data.mounts.varLibCwlWorkflowJson ? data.mounts.varLibCwlWorkflowJson.content.graph[1].inputs.map((it: any) => (
-        { type: it.type, id: it.id, label: it.label, default: getInputValue(it.id, data.mounts.varLibCwlCwlInputJson.content), doc: it.doc }
-    )) : [];
+const getInputs = (data: any) => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return inputs ? inputs.map(
+        (it: any) => (
+            {
+                type: it.type,
+                id: it.id,
+                label: it.label,
+                default: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
+                doc: it.doc
+            }
+    )
+    ) : [];
+};
 
 export const openRemoveProcessDialog = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
index 361825d43fb113a2af85ebf451c4eb496309dfd8..3a42d07eb897677d28889555c6f50fad7069a714 100644 (file)
@@ -28,9 +28,6 @@ import { DataExplorer, getDataExplorer } from '~/store/data-explorer/data-explor
 import { ListResults } from '~/services/common-service/common-service';
 import { loadContainers } from '~/store/processes/processes-actions';
 import { ResourceKind } from '~/models/resource';
-import { getResource } from "~/store/resources/resources";
-import { CollectionResource } from "~/models/collection";
-import { resourcesDataActions } from "~/store/resources-data/resources-data-actions";
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
 import { serializeResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
 import { updatePublicFavorites } from '~/store/public-favorites/public-favorites-actions';
@@ -58,7 +55,6 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                 api.dispatch<any>(updateFavorites(resourceUuids));
                 api.dispatch<any>(updatePublicFavorites(resourceUuids));
                 api.dispatch(updateResources(response.items));
-                api.dispatch<any>(updateResourceData(resourceUuids));
                 await api.dispatch<any>(loadMissingProcessesInformation(response.items));
                 api.dispatch(setItems(response));
             } catch (e) {
@@ -91,19 +87,6 @@ export const loadMissingProcessesInformation = (resources: GroupContentsResource
         }
     };
 
-export const updateResourceData = (resourceUuids: string[]) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        resourceUuids.map(async uuid => {
-            const resource = getResource<CollectionResource>(uuid)(getState().resources);
-            if (resource && resource.kind === ResourceKind.COLLECTION) {
-                const files = await services.collectionService.files(uuid);
-                if (files) {
-                    dispatch(resourcesDataActions.SET_FILES({ uuid, files }));
-                }
-            }
-        });
-    };
-
 export const setItems = (listResults: ListResults<GroupContentsResource>) =>
     projectPanelActions.SET_ITEMS({
         ...listResultsToDataExplorerItemsMeta(listResults),
index 31110175412f32c8dc1363677a223c60e2306e2b..d05e2dc7a1c73e10eff89b3420248836132f913e 100644 (file)
@@ -4,22 +4,23 @@
 
 import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from "~/services/services";
 import { mockProjectResource } from "~/models/test-utils";
 import { treePickerActions, receiveTreePickerProjectsData } from "~/store/tree-picker/tree-picker-actions";
 import { TreePickerId } from '~/models/tree';
 
 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>(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();
-
+    const uuid = getUserUuid(getState());
+    if (!uuid) { return; }
     dispatch<any>(getPickerTreeProjects(uuid));
     dispatch<any>(getSharedWithMeProjectsPickerTree(uuid));
     dispatch<any>(getFavoritesProjectsPickerTree(uuid));
@@ -43,4 +44,4 @@ const getProjectsPickerTree = (uuid: string, kind: string) => {
         [mockProjectResource({ uuid, name: kind })],
         kind
     );
-};
\ No newline at end of file
+};
index ddcd233fd5ad8053dbe60819bdfdf63f364335bb..a303b5518dc7a5b43ceb28cc7028c89764dea4c7 100644 (file)
@@ -5,6 +5,7 @@
 import { Dispatch } from "redux";
 import { reset, startSubmit, stopSubmit, initialize, FormErrors, formValueSelector, change } from 'redux-form';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
 import { ProjectResource } from '~/models/project';
@@ -38,7 +39,7 @@ export const isItemNotInProject = (properties: any) => {
     if (properties.breadcrumbs) {
         return Boolean(properties.breadcrumbs[0].label !== 'Projects');
     } else {
-        return ;
+        return;
     }
 };
 
@@ -47,7 +48,8 @@ export const openProjectCreateDialog = (ownerUuid: string) =>
         const router = getState();
         const properties = getState().properties;
         if (isItemNotInProject(properties) || !isProjectOrRunProcessRoute(router)) {
-            const userUuid = getState().auth.user!.uuid;
+            const userUuid = getUserUuid(getState());
+            if (!userUuid) { return; }
             dispatch(initialize(PROJECT_CREATE_FORM_NAME, { userUuid }));
         } else {
             dispatch(initialize(PROJECT_CREATE_FORM_NAME, { ownerUuid }));
@@ -65,7 +67,7 @@ export const createProject = (project: Partial<ProjectResource>) =>
             return newProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
             }
             return undefined;
@@ -76,12 +78,12 @@ export const addPropertyToCreateProjectForm = (data: ResourcePropertiesFormData)
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const properties = { ...PROJECT_CREATE_FORM_SELECTOR(getState(), 'properties') };
         properties[data.key] = data.value;
-        dispatch(change(PROJECT_CREATE_FORM_NAME, 'properties', properties ));
+        dispatch(change(PROJECT_CREATE_FORM_NAME, 'properties', properties));
     };
 
 export const removePropertyFromCreateProjectForm = (key: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const properties = { ...PROJECT_CREATE_FORM_SELECTOR(getState(), 'properties') };
         delete properties[key];
-        dispatch(change(PROJECT_CREATE_FORM_NAME, 'properties', properties ));
-    };
\ No newline at end of file
+        dispatch(change(PROJECT_CREATE_FORM_NAME, 'properties', properties));
+    };
index 8876be0f252afedd7e1dd0b752a6b4eba8981cf8..4dcaf2f514c914a17c6525fb88f2abb9fc9f1cd3 100644 (file)
@@ -7,6 +7,7 @@ import { dialogActions } from "~/store/dialog/dialog-actions";
 import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
 import { ServiceRepository } from '~/services/services';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
@@ -26,18 +27,18 @@ export const openMoveProjectDialog = (resource: { name: string, uuid: string })
 
 export const moveProject = (resource: MoveToFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = getState().auth.user!.uuid;
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         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 });
+            const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
             await dispatch<any>(loadSidePanelTreeProjects(userUuid));
             return newProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'A project with the same name already exists in the target project.' } as FormErrors));
             } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
                 dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' } as FormErrors));
index b92069762a0e300e62044f0f289510fae68911ae..2449b9ce9bf1a049eeb7790cfe1fc84ee2ef6b3b 100644 (file)
@@ -39,7 +39,7 @@ export const updateProject = (project: Partial<ProjectResource>) =>
             return updatedProject;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
             }
             return ;
index be7f5285953df1fcf0e2feb36b01792a95cd3174..a15fe97542713b9bdec1c80729641d9db05f5e4b 100644 (file)
@@ -53,7 +53,7 @@ export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareServ
             }
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
-                const uuidPrefix = api.getState().config.uuidPrefix;
+                const uuidPrefix = api.getState().auth.config.uuidPrefix;
                 const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
                 const responseLinks = await this.services.linkService.list({
                     limit: dataExplorer.rowsPerPage,
@@ -129,4 +129,4 @@ const couldNotFetchPublicFavorites = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch public favorites contents.',
         kind: SnackbarKind.ERROR
-    });
\ No newline at end of file
+    });
index 50b9070baf0987ab6ec64c4624cf301dee96d02d..d5a5cd46264778a82913adeb88866aa72dc08c04 100644 (file)
@@ -21,7 +21,7 @@ export type PublicFavoritesAction = UnionOf<typeof publicFavoritesActions>;
 export const togglePublicFavorite = (resource: { uuid: string; name: string }) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
         dispatch(progressIndicatorActions.START_WORKING("togglePublicFavorite"));
-        const uuidPrefix = getState().config.uuidPrefix;
+        const uuidPrefix = getState().auth.config.uuidPrefix;
         const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
         dispatch(publicFavoritesActions.TOGGLE_PUBLIC_FAVORITE({ resourceUuid: resource.uuid }));
         const isPublicFavorite = checkPublicFavorite(resource.uuid, getState().publicFavorites);
@@ -57,7 +57,7 @@ export const togglePublicFavorite = (resource: { uuid: string; name: string }) =
 
 export const updatePublicFavorites = (resourceUuids: string[]) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const uuidPrefix = getState().config.uuidPrefix;
+        const uuidPrefix = getState().auth.config.uuidPrefix;
         const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
         dispatch(publicFavoritesActions.CHECK_PRESENCE_IN_PUBLIC_FAVORITES(resourceUuids));
         services.favoriteService
index 3b77defe1a3f45730df3686b0d8eeeb9159568c3..239b531c61e939b5ea512fe9d0fd23e17e257b7a 100644 (file)
@@ -5,6 +5,7 @@
 import { Dispatch } from "redux";
 import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from "~/services/services";
 import { navigateToRepositories } from "~/store/navigation/navigation-action";
 import { unionize, ofType, UnionOf } from "~/common/unionize";
@@ -40,7 +41,8 @@ export const openRepositoryAttributes = (uuid: string) =>
 
 export const openRepositoryCreateDialog = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = await services.authService.getUuid();
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         const user = await services.userService.get(userUuid!);
         dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
         dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME, data: { user } }));
@@ -48,7 +50,8 @@ export const openRepositoryCreateDialog = () =>
 
 export const createRepository = (repository: RepositoryResource) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = await services.authService.getUuid();
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         const user = await services.userService.get(userUuid!);
         dispatch(startSubmit(REPOSITORY_CREATE_FORM_NAME));
         try {
diff --git a/src/store/resources-data/resources-data-actions.ts b/src/store/resources-data/resources-data-actions.ts
deleted file mode 100644 (file)
index 7bc0e94..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { unionize, ofType, UnionOf } from "~/common/unionize";
-import { CollectionDirectory, CollectionFile } from "~/models/collection-file";
-import { Tree } from "~/models/tree";
-
-export const resourcesDataActions = unionize({
-    SET_FILES: ofType<{uuid: string, files: Tree<CollectionFile | CollectionDirectory>}>()
-});
-
-export type ResourcesDataActions = UnionOf<typeof resourcesDataActions>;
diff --git a/src/store/resources-data/resources-data-reducer.ts b/src/store/resources-data/resources-data-reducer.ts
deleted file mode 100644 (file)
index 07a3a66..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { ResourcesDataActions, resourcesDataActions } from "~/store/resources-data/resources-data-actions";
-import { getNodeDescendantsIds, TREE_ROOT_ID } from "~/models/tree";
-import { CollectionFileType } from "~/models/collection-file";
-
-export interface ResourceData {
-    fileCount: number;
-    fileSize: number;
-}
-
-export type ResourcesDataState = {
-    [key: string]: ResourceData
-};
-
-export const resourcesDataReducer = (state: ResourcesDataState = {}, action: ResourcesDataActions) =>
-    resourcesDataActions.match(action, {
-        SET_FILES: ({uuid, files}) => {
-            const flattenFiles = getNodeDescendantsIds(TREE_ROOT_ID)(files).map(id => files[id]);
-            const [fileSize, fileCount] = flattenFiles.reduce(([size, cnt], f) =>
-                f && f.value.type === CollectionFileType.FILE
-                ? [size + f.value.size, cnt + 1]
-                : [size, cnt]
-            , [0, 0]);
-            return {
-                ...state,
-                [uuid]: { fileCount, fileSize }
-            };
-        },
-        default: () => state,
-    });
diff --git a/src/store/resources-data/resources-data.ts b/src/store/resources-data/resources-data.ts
deleted file mode 100644 (file)
index 48c1e2b..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { ResourceData, ResourcesDataState } from "~/store/resources-data/resources-data-reducer";
-
-export const getResourceData = (id: string) =>
-    (state: ResourcesDataState): ResourceData | undefined => state[id];
index f2a1c4b2d797f233504dedac9f511862f4b91ffa..6213c60a91e643559decbf8855c9351eb5a72b91 100644 (file)
@@ -2,9 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 
 export const RICH_TEXT_EDITOR_DIALOG_NAME = 'richTextEditorDialogName';
-export const openRichTextEditorDialog = (title: string, text: string) => 
+export const openRichTextEditorDialog = (title: string, text: string) =>
     dialogActions.OPEN_DIALOG({ id: RICH_TEXT_EDITOR_DIALOG_NAME, data: { title, text } });
\ No newline at end of file
index 5b082b8c28eaffc19199f8f757fd5fa4f0b4a5eb..66c784f944460dbc5f20b11926e27045baea1589 100644 (file)
@@ -6,6 +6,7 @@ import { Dispatch } from 'redux';
 import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { ServiceRepository } from "~/services/services";
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { WorkflowResource, getWorkflowInputs, parseWorkflowDefinition } from '~/models/workflow';
 import { getFormValues, initialize } from 'redux-form';
 import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from '~/views/run-process-panel/run-process-basic-form';
@@ -118,7 +119,8 @@ export const runProcess = async (dispatch: Dispatch<any>, getState: () => RootSt
     const basicForm = getFormValues(RUN_PROCESS_BASIC_FORM)(state) as RunProcessBasicFormData;
     const inputsForm = getFormValues(RUN_PROCESS_INPUTS_FORM)(state) as WorkflowInputsData;
     const advancedForm = getFormValues(RUN_PROCESS_ADVANCED_FORM)(state) as RunProcessAdvancedFormData || DEFAULT_ADVANCED_FORM_VALUES;
-    const userUuid = getState().auth.user!.uuid;
+    const userUuid = getUserUuid(getState());
+    if (!userUuid) { return; }
     const pathname = getState().runProcessPanel.processPathname;
     const { processOwnerUuid, selectedWorkflow } = state.runProcessPanel;
     if (selectedWorkflow) {
@@ -135,7 +137,7 @@ export const runProcess = async (dispatch: Dispatch<any>, getState: () => RootSt
                 api: advancedForm[API_FIELD],
             },
             schedulingParameters: {
-                maxRunTime: advancedForm[RUNTIME_FIELD]
+                max_run_time: advancedForm[RUNTIME_FIELD]
             },
             containerImage: 'arvados/jobs',
             cwd: '/var/spool/cwl',
index 68efb4e6f2f6b0651a8aa409f65fd5f2b4d72a61..ddaf8f317ddae98aa86660a443ad59f3235f16f4 100644 (file)
@@ -5,17 +5,16 @@
 import { ofType, unionize, UnionOf } from "~/common/unionize";
 import { GroupContentsResource, GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
 import { Dispatch } from 'redux';
-import { arrayPush, change, initialize } from 'redux-form';
+import { change, initialize, untouch } from 'redux-form';
 import { RootState } from '~/store/store';
 import { initUserProject, treePickerActions } from '~/store/tree-picker/tree-picker-actions';
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from "~/services/api/filter-builder";
-import { ResourceKind, isResourceUuid, extractUuidKind, RESOURCE_UUID_REGEX, COLLECTION_PDH_REGEX } from '~/models/resource';
+import { ResourceKind, RESOURCE_UUID_REGEX, COLLECTION_PDH_REGEX } from '~/models/resource';
 import { SearchView } from '~/store/search-bar/search-bar-reducer';
 import { navigateTo, navigateToSearchResults } from '~/store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { PropertyValue, SearchBarAdvanceFormData } from '~/models/search-bar';
-import { debounce } from 'debounce';
+import { PropertyValue, SearchBarAdvancedFormData } from '~/models/search-bar';
 import * as _ from "lodash";
 import { getModifiedKeysValues } from "~/common/objects";
 import { activateSearchBarProject } from "~/store/search-bar/search-bar-tree-actions";
@@ -24,6 +23,7 @@ import { searchResultsPanelActions } from "~/store/search-results-panel/search-r
 import { ListResults } from "~/services/common-service/common-service";
 import * as parser from './search-query/arv-parser';
 import { Keywords } from './search-query/arv-parser';
+import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "~/models/vocabulary";
 
 export const searchBarActions = unionize({
     SET_CURRENT_VIEW: ofType<string>(),
@@ -31,9 +31,9 @@ export const searchBarActions = unionize({
     CLOSE_SEARCH_VIEW: ofType<{}>(),
     SET_SEARCH_RESULTS: ofType<GroupContentsResource[]>(),
     SET_SEARCH_VALUE: ofType<string>(),
-    SET_SAVED_QUERIES: ofType<SearchBarAdvanceFormData[]>(),
+    SET_SAVED_QUERIES: ofType<SearchBarAdvancedFormData[]>(),
     SET_RECENT_QUERIES: ofType<string[]>(),
-    UPDATE_SAVED_QUERY: ofType<SearchBarAdvanceFormData[]>(),
+    UPDATE_SAVED_QUERY: ofType<SearchBarAdvancedFormData[]>(),
     SET_SELECTED_ITEM: ofType<string>(),
     MOVE_UP: ofType<{}>(),
     MOVE_DOWN: ofType<{}>(),
@@ -42,9 +42,9 @@ export const searchBarActions = unionize({
 
 export type SearchBarActions = UnionOf<typeof searchBarActions>;
 
-export const SEARCH_BAR_ADVANCE_FORM_NAME = 'searchBarAdvanceFormName';
+export const SEARCH_BAR_ADVANCED_FORM_NAME = 'searchBarAdvancedFormName';
 
-export const SEARCH_BAR_ADVANCE_FORM_PICKER_ID = 'searchBarAdvanceFormPickerId';
+export const SEARCH_BAR_ADVANCED_FORM_PICKER_ID = 'searchBarAdvancedFormPickerId';
 
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
 
@@ -71,21 +71,22 @@ export const searchData = (searchValue: string) =>
             dispatch<any>(searchGroups(searchValue, 5));
             if (currentView === SearchView.BASIC) {
                 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
-                dispatch(navigateToSearchResults);
+                dispatch(navigateToSearchResults(searchValue));
             }
         }
     };
 
-export const searchAdvanceData = (data: SearchBarAdvanceFormData) =>
-    async (dispatch: Dispatch) => {
+export const searchAdvancedData = (data: SearchBarAdvancedFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
         dispatch<any>(saveQuery(data));
+        const searchValue = getState().searchBar.searchValue;
         dispatch(searchResultsPanelActions.CLEAR());
         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
-        dispatch(navigateToSearchResults);
+        dispatch(navigateToSearchResults(searchValue));
     };
 
-export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) =>
+export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const searchValue = getState().searchBar.searchValue;
         const value = getQueryFromAdvancedData({
@@ -95,17 +96,17 @@ export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, p
         dispatch(searchBarActions.SET_SEARCH_VALUE(value));
     };
 
-export const setAdvancedDataFromSearchValue = (search: string) =>
+export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) =>
     async (dispatch: Dispatch) => {
-        const data = getAdvancedDataFromQuery(search);
-        dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
+        const data = getAdvancedDataFromQuery(search, vocabulary);
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
         if (data.projectUuid) {
             await dispatch<any>(activateSearchBarProject(data.projectUuid));
-            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID, id: data.projectUuid }));
+            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID, id: data.projectUuid }));
         }
     };
 
-const saveQuery = (data: SearchBarAdvanceFormData) =>
+const saveQuery = (data: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const savedQueries = services.searchService.getSavedQueries();
         if (data.saveQuery && data.queryName) {
@@ -131,11 +132,11 @@ export const deleteSavedQuery = (id: number) =>
         return savedSearchQueries || [];
     };
 
-export const editSavedQuery = (data: SearchBarAdvanceFormData) =>
+export const editSavedQuery = (data: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch<any>) => {
         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED));
         dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data)));
-        dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
     };
 
 export const openSearchView = () =>
@@ -156,7 +157,7 @@ export const closeSearchView = () =>
 export const closeAdvanceView = () =>
     (dispatch: Dispatch<any>) => {
         dispatch(searchBarActions.SET_SEARCH_VALUE(''));
-        dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
     };
 
@@ -198,17 +199,11 @@ export const submitData = (event: React.FormEvent<HTMLFormElement>) =>
             dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
             dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
             dispatch(searchResultsPanelActions.CLEAR());
-            dispatch(navigateToSearchResults);
+            dispatch(navigateToSearchResults(searchValue));
         }
     };
 
 
-const startSearch = () =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const searchValue = getState().searchBar.searchValue;
-        dispatch<any>(searchData(searchValue));
-    };
-
 const searchGroups = (searchValue: string, limit: number) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const currentView = getState().searchBar.currentView;
@@ -261,10 +256,10 @@ const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | '
     return value;
 };
 
-export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => {
+export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) => {
     let value = '';
 
-    const flatData = (data: SearchBarAdvanceFormData) => {
+    const flatData = (data: SearchBarAdvancedFormData) => {
         const fo = {
             searchValue: data.searchValue,
             type: data.type,
@@ -274,7 +269,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
             dateFrom: data.dateFrom,
             dateTo: data.dateTo,
         };
-        (data.properties || []).forEach(p => fo[`prop-"${p.key}"`] = `"${p.value}"`);
+        (data.properties || []).forEach(p => fo[`prop-"${p.keyID || p.key}"`] = `"${p.valueID || p.value}"`);
         return fo;
     };
 
@@ -287,16 +282,14 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
         ['to', 'dateTo']
     ];
     _.union(data.properties, prevData ? prevData.properties : [])
-        .forEach(p => keyMap.push([`has:"${p.key}"`, `prop-"${p.key}"`]));
+        .forEach(p => keyMap.push([`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}"`]));
 
     if (prevData) {
-        const fd = flatData(data);
-        const pfd = flatData(prevData);
         const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
         value = buildQueryFromKeyMap({
             searchValue: data.searchValue,
             ...obj
-        } as SearchBarAdvanceFormData, keyMap, "reuse");
+        } as SearchBarAdvancedFormData, keyMap, "reuse");
     } else {
         value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
     }
@@ -305,7 +298,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
     return value;
 };
 
-export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => {
+export const getAdvancedDataFromQuery = (query: string, vocabulary?: Vocabulary): SearchBarAdvancedFormData => {
     const { tokens, searchString } = parser.parseSearchQuery(query);
     const getValue = parser.getValue(tokens);
     return {
@@ -316,7 +309,17 @@ export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormDat
         inTrash: parser.isTrashed(tokens),
         dateFrom: getValue(Keywords.FROM) || '',
         dateTo: getValue(Keywords.TO) || '',
-        properties: parser.getProperties(tokens),
+        properties: vocabulary
+            ? parser.getProperties(tokens).map(
+                p => {
+                    return {
+                        keyID: p.key,
+                        key: getTagKeyLabel(p.key, vocabulary),
+                        valueID: p.value,
+                        value: getTagValueLabel(p.key, p.value, vocabulary),
+                    };
+                })
+            : parser.getProperties(tokens),
         saveQuery: false,
         queryName: ''
     };
@@ -369,19 +372,20 @@ const buildDateFilter = (date?: string): string => {
     return date ? date : '';
 };
 
-export const initAdvanceFormProjectsTree = () =>
+export const initAdvancedFormProjectsTree = () =>
     (dispatch: Dispatch) => {
-        dispatch<any>(initUserProject(SEARCH_BAR_ADVANCE_FORM_PICKER_ID));
+        dispatch<any>(initUserProject(SEARCH_BAR_ADVANCED_FORM_PICKER_ID));
     };
 
-export const changeAdvanceFormProperty = (property: string, value: PropertyValue[] | string = '') =>
+export const changeAdvancedFormProperty = (propertyField: string, value: PropertyValue[] | string = '') =>
     (dispatch: Dispatch) => {
-        dispatch(change(SEARCH_BAR_ADVANCE_FORM_NAME, property, value));
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, value));
     };
 
-export const updateAdvanceFormProperties = (propertyValues: PropertyValue) =>
+export const resetAdvancedFormProperty = (propertyField: string) =>
     (dispatch: Dispatch) => {
-        dispatch(arrayPush(SEARCH_BAR_ADVANCE_FORM_NAME, 'properties', propertyValues));
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, null));
+        dispatch(untouch(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField));
     };
 
 export const moveUp = () =>
index 4f663eeb393f6f1ea115ca43d42152bcce5ca0cc..32d9305f956df81c69a356096f2a4438bbf0a9bf 100644 (file)
@@ -8,7 +8,7 @@ import {
     SearchBarActions
 } from '~/store/search-bar/search-bar-actions';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 
 type SearchResult = GroupContentsResource;
 export type SearchBarSelectedItem = {
@@ -21,7 +21,7 @@ interface SearchBar {
     open: boolean;
     searchResults: SearchResult[];
     searchValue: string;
-    savedQueries: SearchBarAdvanceFormData[];
+    savedQueries: SearchBarAdvancedFormData[];
     recentQueries: string[];
     selectedItem: SearchBarSelectedItem;
 }
@@ -47,7 +47,7 @@ const initialState: SearchBar = {
 
 const makeSelectedItem = (id: string, query?: string): SearchBarSelectedItem => ({ id, query: query ? query : id });
 
-const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvanceFormData[]) => {
+const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvancedFormData[]) => {
     const recentIds = recentQueries.map((q, idx) => makeSelectedItem(`RQ-${idx}-${q}`, q));
     const savedIds = savedQueries.map((q, idx) => makeSelectedItem(`SQ-${idx}-${q.queryName}`, getQueryFromAdvancedData(q)));
     return recentIds.concat(savedIds);
index 5101055a45a7b95e77dfeacffd7648b7929d2104..c97d77b35c4ce2308c1f2698893c5ac5fab9733e 100644 (file)
@@ -6,16 +6,17 @@ import { getTreePicker, TreePicker } from "~/store/tree-picker/tree-picker";
 import { getNode, getNodeAncestorsIds, initTreeNode, TreeNodeStatus } from "~/models/tree";
 import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from "~/services/services";
 import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
 import { FilterBuilder } from "~/services/api/filter-builder";
 import { OrderBuilder } from "~/services/api/order-builder";
 import { ProjectResource } from "~/models/project";
 import { resourcesActions } from "~/store/resources/resources-actions";
-import { SEARCH_BAR_ADVANCE_FORM_PICKER_ID } from "~/store/search-bar/search-bar-actions";
+import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from "~/store/search-bar/search-bar-actions";
 
 const getSearchBarTreeNode = (id: string) => (treePicker: TreePicker) => {
-    const searchTree = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(treePicker);
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
     return searchTree
         ? getNode(id)(searchTree)
         : undefined;
@@ -23,7 +24,7 @@ const getSearchBarTreeNode = (id: string) => (treePicker: TreePicker) => {
 
 export const loadSearchBarTreeProjects = (projectUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
-        const treePicker = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(getState().treePicker);
+        const treePicker = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(getState().treePicker);
         const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
         if (node || projectUuid === '') {
             await dispatch<any>(loadSearchBarProject(projectUuid));
@@ -31,15 +32,17 @@ export const loadSearchBarTreeProjects = (projectUuid: string) =>
     };
 
 export const getSearchBarTreeNodeAncestorsIds = (id: string) => (treePicker: TreePicker) => {
-    const searchTree = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(treePicker);
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
     return searchTree
         ? getNodeAncestorsIds(id)(searchTree)
         : [];
 };
 
 export const activateSearchBarTreeBranch = (id: string) =>
-    async (dispatch: Dispatch, _: void, services: ServiceRepository) => {
-        const ancestors = await services.ancestorsService.ancestors(id, services.authService.getUuid() || '');
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const ancestors = await services.ancestorsService.ancestors(id, userUuid);
 
         for (const ancestor of ancestors) {
             await dispatch<any>(loadSearchBarTreeProjects(ancestor.uuid));
@@ -49,16 +52,16 @@ export const activateSearchBarTreeBranch = (id: string) =>
                 ...[],
                 ...ancestors.map(ancestor => ancestor.uuid)
             ],
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
         }));
-        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
     };
 
 export const expandSearchBarTreeItem = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         const node = getSearchBarTreeNode(id)(getState().treePicker);
         if (node && !node.expanded) {
-            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         }
     };
 
@@ -73,7 +76,7 @@ export const activateSearchBarProject = (id: string) =>
         }
         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
             ids: getSearchBarTreeNodeAncestorsIds(id)(treePicker),
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
         }));
         dispatch<any>(expandSearchBarTreeItem(id));
     };
@@ -81,7 +84,7 @@ export const activateSearchBarProject = (id: string) =>
 
 const loadSearchBarProject = (projectUuid: string) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         const params = {
             filters: new FilterBuilder()
                 .addEqual('ownerUuid', projectUuid)
@@ -93,9 +96,8 @@ const loadSearchBarProject = (projectUuid: string) =>
         const { items } = await services.projectService.list(params);
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
             id: projectUuid,
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID,
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
             nodes: items.map(item => initTreeNode({ id: item.uuid, value: item })),
         }));
         dispatch(resourcesActions.SET_RESOURCES(items));
     };
-
index 9d7e3207f0973379b5ae5cdf4c66baea912c777e..84e68ab0d07d7a4ed2efacd39e287772f375a279 100644 (file)
@@ -10,7 +10,6 @@ import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions
 import { DataExplorer, getDataExplorer } from '~/store/data-explorer/data-explorer-reducer';
 import { updateResources } from '~/store/resources/resources-actions';
 import { SortDirection } from '~/components/data-table/data-column';
-import { SearchResultsPanelColumnNames } from '~/views/search-results-panel/search-results-panel-view';
 import { OrderDirection, OrderBuilder } from '~/services/api/order-builder';
 import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
 import { ListResults } from '~/services/common-service/common-service';
@@ -25,7 +24,6 @@ import { joinFilters } from '~/services/api/filter-builder';
 import { DataColumns } from '~/components/data-table/data-table';
 import { serializeResourceTypeFilters } from '~/store//resource-type-filters/resource-type-filters';
 import { ProjectPanelColumnNames } from '~/views/project-panel/project-panel';
-import * as _ from 'lodash';
 import { Resource } from '~/models/resource';
 
 export class SearchResultsMiddlewareService extends DataExplorerMiddlewareService {
@@ -44,36 +42,29 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
             return;
         }
 
-        try {
-            const params = getParams(dataExplorer, searchValue);
+        const params = getParams(dataExplorer, searchValue);
 
-            const responses = await Promise.all(sessions.map(session =>
-                this.services.groupsService.contents('', params, session)
-            ));
+        const initial = {
+            itemsAvailable: 0,
+            items: [] as GroupContentsResource[],
+            kind: '',
+            offset: 0,
+            limit: 10
+        };
 
-            const initial = {
-                itemsAvailable: 0,
-                items: [] as GroupContentsResource[],
-                kind: '',
-                offset: 0,
-                limit: 10
-            };
-
-            const mergedResponse = responses.reduce((merged, current) => ({
-                ...merged,
-                itemsAvailable: merged.itemsAvailable + current.itemsAvailable,
-                items: merged.items.concat(current.items)
-            }), initial);
-
-            api.dispatch(updateResources(mergedResponse.items));
-
-            api.dispatch(criteriaChanged
-                ? setItems(mergedResponse)
-                : appendItems(mergedResponse));
-
-        } catch {
-            api.dispatch(couldNotFetchSearchResults());
+        if (criteriaChanged) {
+            api.dispatch(setItems(initial));
         }
+
+        sessions.map(session =>
+            this.services.groupsService.contents('', params, session)
+                .then((response) => {
+                    api.dispatch(updateResources(response.items));
+                    api.dispatch(appendItems(response));
+                }).catch(() => {
+                    api.dispatch(couldNotFetchSearchResults(session.clusterId));
+                })
+        );
     }
 }
 
@@ -119,8 +110,8 @@ export const appendItems = (listResults: ListResults<GroupContentsResource>) =>
         items: listResults.items.map(resource => resource.uuid),
     });
 
-const couldNotFetchSearchResults = () =>
+const couldNotFetchSearchResults = (cluster: string) =>
     snackbarActions.OPEN_SNACKBAR({
-        message: `Could not fetch search results for some sessions.`,
+        message: `Could not fetch search results from ${cluster}.`,
         kind: SnackbarKind.ERROR
     });
index d41191c002571753ed082b466e931578acae452b..20a14b2e7abef94b8f3bacece36422f2bf4e6078 100644 (file)
@@ -7,6 +7,7 @@ import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
 import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { searchBarActions } from '~/store/search-bar/search-bar-actions';
 
 export const SEARCH_RESULTS_PANEL_ID = "searchResultsPanel";
 export const searchResultsPanelActions = bindDataExplorerActions(SEARCH_RESULTS_PANEL_ID);
@@ -14,5 +15,15 @@ export const searchResultsPanelActions = bindDataExplorerActions(SEARCH_RESULTS_
 export const loadSearchResultsPanel = () =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(setBreadcrumbs([{ label: 'Search results' }]));
+        const loc = getState().router.location;
+        if (loc !== null) {
+            const search = new URLSearchParams(loc.search);
+            const q = search.get('q');
+            if (q !== null) {
+                dispatch(searchBarActions.SET_SEARCH_VALUE(q));
+            }
+        }
+        dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+        dispatch(searchResultsPanelActions.CLEAR());
         dispatch(searchResultsPanelActions.REQUEST_ITEMS(true));
-    };
\ No newline at end of file
+    };
index 17b237ecc60b746e0aba35f34612d20a9860e783..671c10fc3088ba8dc4630f93c83afcd55066709d 100644 (file)
@@ -20,7 +20,6 @@ import { withProgress } from "~/store/progress-indicator/with-progress";
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions.ts';
 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
 import { extractUuidKind, ResourceKind } from "~/models/resource";
-import { LinkClass } from "~/models/link";
 
 export const openSharingDialog = (resourceUuid: string) =>
     (dispatch: Dispatch) => {
@@ -74,19 +73,23 @@ const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState,
 };
 
 const initializeManagementForm = (permissionLinks: PermissionResource[]) =>
-    async (dispatch: Dispatch, getState: () => RootState, { userService }: ServiceRepository) => {
+    async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService }: ServiceRepository) => {
 
         const filters = new FilterBuilder()
             .addIn('uuid', permissionLinks.map(({ tailUuid }) => tailUuid))
             .getFilters();
 
         const { items: users } = await userService.list({ filters });
+        const { items: groups} = await groupsService.list({ filters });
 
         const getEmail = (tailUuid: string) => {
             const user = users.find(({ uuid }) => uuid === tailUuid);
+            const group = groups.find(({ uuid }) => uuid === tailUuid);
             return user
                 ? user.email
-                : tailUuid;
+                : group
+                    ? group.name
+                    : tailUuid;
         };
 
         const managementPermissions = permissionLinks
@@ -200,21 +203,6 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss
 
         const getGroupsFromForm = invitations.invitedPeople.filter((invitation) => extractUuidKind(invitation.uuid) === ResourceKind.GROUP);
         const getUsersFromForm = invitations.invitedPeople.filter((invitation) => extractUuidKind(invitation.uuid) === ResourceKind.USER);
-        const uuids = getGroupsFromForm.map(group => group.uuid);
-
-        const permissions = await permissionService.list({
-            filters: new FilterBuilder()
-                .addIn('tailUuid', uuids)
-                .addEqual('linkClass', LinkClass.PERMISSION)
-                .getFilters()
-        });
-
-        const usersFromGroups = await userService.list({
-            filters: new FilterBuilder()
-                .addIn('uuid', permissions.items.map(item => item.headUuid))
-                .getFilters()
-
-        });
 
         const invitationDataUsers = getUsersFromForm
             .map(person => ({
@@ -224,11 +212,11 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss
                 name: invitations.permissions
             }));
 
-        const invitationsDataGroups = usersFromGroups.items.map(
-            person => ({
+        const invitationsDataGroups = getGroupsFromForm.map(
+            group => ({
                 ownerUuid: user.uuid,
                 headUuid: dialog.data,
-                tailUuid: person.uuid,
+                tailUuid: group.uuid,
                 name: invitations.permissions
             })
         );
index 6ad71391db199f8cd8d504be0892c96293b24cb8..1bb1624df7cdd58935c561493bb18e89ce529301 100644 (file)
@@ -5,6 +5,7 @@
 import { Dispatch } from 'redux';
 import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { resourcesActions } from '~/store/resources/resources-actions';
@@ -52,8 +53,9 @@ const SIDE_PANEL_CATEGORIES = [
 export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id);
 
 export const initSidePanelTree = () =>
-    (dispatch: Dispatch, _: () => RootState, { authService }: ServiceRepository) => {
-        const rootProjectUuid = authService.getUuid() || '';
+    (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => {
+        const rootProjectUuid = getUserUuid(getState());
+        if (!rootProjectUuid) { return; }
         const nodes = SIDE_PANEL_CATEGORIES.map(id => initTreeNode({ id, value: id }));
         const projectsNode = initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
         const sharedNode = initTreeNode({ id: SidePanelTreeCategory.SHARED_WITH_ME, value: SidePanelTreeCategory.SHARED_WITH_ME });
@@ -155,9 +157,11 @@ export const activateSidePanelTreeProject = (id: string) =>
     };
 
 export const activateSidePanelTreeBranch = (id: string) =>
-    async (dispatch: Dispatch, _: void, services: ServiceRepository) => {
-        const ancestors = await services.ancestorsService.ancestors(id, services.authService.getUuid() || '');
-        const isShared = ancestors.every(({ uuid }) => uuid !== services.authService.getUuid());
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
+        const ancestors = await services.ancestorsService.ancestors(id, userUuid);
+        const isShared = ancestors.every(({ uuid }) => uuid !== userUuid);
         if (isShared) {
             await dispatch<any>(loadSidePanelTreeProjects(SidePanelTreeCategory.SHARED_WITH_ME));
         }
index 8a2ca2400cb1cbd3f1229cce96294291e91c8d66..76a3b7e4398e3d38a84dfc3623b82bfd9eb8f0ed 100644 (file)
@@ -8,7 +8,7 @@ import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
 
 import { authReducer } from "./auth/auth-reducer";
-import { configReducer } from "./config/config-reducer";
+import { authMiddleware } from "./auth/auth-middleware";
 import { dataExplorerReducer } from './data-explorer/data-explorer-reducer';
 import { detailsPanelReducer } from './details-panel/details-panel-reducer';
 import { contextMenuReducer } from './context-menu/context-menu-reducer';
@@ -27,7 +27,6 @@ import { ServiceRepository } from "~/services/services";
 import { treePickerReducer } from './tree-picker/tree-picker-reducer';
 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';
 import { TrashPanelMiddlewareService } from "~/store/trash-panel/trash-panel-middleware-service";
 import { TRASH_PANEL_ID } from "~/store/trash-panel/trash-panel-action";
@@ -43,7 +42,6 @@ import { appInfoReducer } from '~/store/app-info/app-info-reducer';
 import { searchBarReducer } from './search-bar/search-bar-reducer';
 import { SEARCH_RESULTS_PANEL_ID } from '~/store/search-results-panel/search-results-panel-actions';
 import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service';
-import { resourcesDataReducer } from "~/store/resources-data/resources-data-reducer";
 import { virtualMachinesReducer } from "~/store/virtual-machines/virtual-machines-reducer";
 import { repositoriesReducer } from '~/store/repositories/repositories-reducer';
 import { keepServicesReducer } from '~/store/keep-services/keep-services-reducer';
@@ -126,6 +124,7 @@ export function configureStore(history: History, services: ServiceRepository): R
     const middlewares: Middleware[] = [
         routerMiddleware(history),
         thunkMiddleware.withExtraArgument(services),
+        authMiddleware(services),
         projectPanelMiddleware,
         favoritePanelMiddleware,
         trashPanelMiddleware,
@@ -147,7 +146,6 @@ export function configureStore(history: History, services: ServiceRepository): R
 
 const createRootReducer = (services: ServiceRepository) => combineReducers({
     auth: authReducer(services),
-    config: configReducer,
     collectionPanel: collectionPanelReducer,
     collectionPanelFiles: collectionPanelFilesReducer,
     contextMenu: contextMenuReducer,
@@ -161,7 +159,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     processLogsPanel: processLogsPanelReducer,
     properties: propertiesReducer,
     resources: resourcesReducer,
-    resourcesData: resourcesDataReducer,
     router: routerReducer,
     snackbar: snackbarReducer,
     treePicker: treePickerReducer,
index f5173fc98cceea69eedaadfa80a822e97a5e3e21..bf7fae56ece038391cc7bde875ce7a8baf590a26 100644 (file)
@@ -7,6 +7,7 @@ import {
     listResultsToDataExplorerItemsMeta
 } from "../data-explorer/data-explorer-middleware-service";
 import { RootState } from "../store";
+import { getUserUuid } from "~/common/getuser";
 import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
 import { SortDirection } from "~/components/data-table/data-column";
@@ -64,9 +65,10 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
                 .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT);
         }
 
+        const userUuid = getUserUuid(api.getState());
+        if (!userUuid) { return; }
         try {
             api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
-            const userUuid = this.services.authService.getUuid()!;
             const listResults = await this.services.groupsService
                 .contents(userUuid, {
                     ...dataExplorerToListParams(dataExplorer),
@@ -104,4 +106,3 @@ const couldNotFetchTrashContents = () =>
         message: 'Could not fetch trash contents.',
         kind: SnackbarKind.ERROR
     });
-
index e4d6d9339508893208d43ef784983ccad501c8f0..ff36e3e68b855d37a0b670d8331521fec7f2d801 100644 (file)
@@ -4,8 +4,10 @@
 
 import { unionize, ofType, UnionOf } from "~/common/unionize";
 import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from '~/models/tree';
+import { createCollectionFilesTree } from "~/models/collection-file";
 import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { pipe, values } from 'lodash/fp';
@@ -17,6 +19,8 @@ import { OrderBuilder } from '~/services/api/order-builder';
 import { ProjectResource } from '~/models/project';
 import { mapTree } from '../../models/tree';
 import { LinkResource, LinkClass } from "~/models/link";
+import { mapTreeValues } from "~/models/tree";
+import { sortFilesTree } from "~/services/collection-service/collection-service-files-response";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
@@ -139,7 +143,10 @@ export const loadCollection = (id: string, pickerId: string) =>
             const node = getNode(id)(picker);
             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
 
-                const filesTree = await services.collectionService.files(node.value.portableDataHash);
+                const files = await services.collectionService.files(node.value.portableDataHash);
+                const tree = createCollectionFilesTree(files);
+                const sorted = sortFilesTree(tree);
+                const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
 
                 dispatch(
                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
@@ -156,7 +163,7 @@ export const loadCollection = (id: string, pickerId: string) =>
 
 export const initUserProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const uuid = services.authService.getUuid();
+        const uuid = getUserUuid(getState());
         if (uuid) {
             dispatch(receiveTreePickerData({
                 id: '',
@@ -172,7 +179,7 @@ export const initUserProject = (pickerId: string) =>
     };
 export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const uuid = services.authService.getUuid();
+        const uuid = getUserUuid(getState());
         if (uuid) {
             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles }));
         }
@@ -232,9 +239,8 @@ interface LoadFavoritesProjectParams {
 export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const { pickerId, includeCollections = false, includeFiles = false } = params;
-        const uuid = services.authService.getUuid();
+        const uuid = getUserUuid(getState());
         if (uuid) {
-
             const filters = pipe(
                 (fb: FilterBuilder) => includeCollections
                     ? fb.addIsA('headUuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
@@ -264,7 +270,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const { pickerId, includeCollections = false, includeFiles = false } = params;
-        const uuidPrefix = getState().config.uuidPrefix;
+        const uuidPrefix = getState().auth.config.uuidPrefix;
         const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
         if (uuid) {
 
@@ -313,7 +319,8 @@ export const loadProjectTreePickerProjects = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
 
-        const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
+
+        const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
         const { items } = await services.projectService.list(buildParams(ownerUuid));
 
         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
@@ -321,7 +328,7 @@ export const loadProjectTreePickerProjects = (id: string) =>
 
 export const loadFavoriteTreePickerProjects = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const parentId = services.authService.getUuid() || '';
+        const parentId = getUserUuid(getState()) || '';
 
         if (id === '') {
             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
@@ -337,7 +344,7 @@ export const loadFavoriteTreePickerProjects = (id: string) =>
 
 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const parentId = services.authService.getUuid() || '';
+        const parentId = getUserUuid(getState()) || '';
 
         if (id === '') {
             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
@@ -360,4 +367,4 @@ const buildParams = (ownerUuid: string) => {
             .addAsc('name')
             .getOrder()
     };
-};
\ No newline at end of file
+};
index fb9bc50c7fbc8202399812cf26241065616a3045..a2f239764c62ceed06f231d4f50cece77525eb20 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, deselectNode, selectNode, selectNodes, deselectNodes } from '~/models/tree';
+import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, selectNodes, deselectNodes } from '~/models/tree';
 import { TreePicker } from "./tree-picker";
 import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
 import { compose } from "redux";
index 1a567e9db08017ca227a3182848370b179fcae1b..f6287260d67e816603ea9ef47ad139932ca0118d 100644 (file)
@@ -5,6 +5,7 @@
 import { Dispatch } from "redux";
 import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
 import { RootState } from '~/store/store';
+import { getUserUuid } from "~/common/getuser";
 import { ServiceRepository } from "~/services/services";
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { startSubmit, reset } from "redux-form";
@@ -12,7 +13,7 @@ import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions
 import { UserResource } from "~/models/user";
 import { getResource } from '~/store/resources/resources';
 import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
-import { saveApiToken } from '~/store/auth/auth-action';
+import { authActions } from '~/store/auth/auth-action';
 
 export const USERS_PANEL_ID = 'usersPanel';
 export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
@@ -53,18 +54,18 @@ export const loginAs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { resources } = getState();
         const data = getResource<UserResource>(uuid)(resources);
+        const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
         if (data) {
-            services.authService.saveUser(data);
+            dispatch<any>(authActions.INIT_USER({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+            location.reload();
+            dispatch<any>(navigateToRootProject);
         }
-        const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
-        dispatch<any>(saveApiToken(`v2/${client.uuid}/${client.apiToken}`));
-        location.reload();
-        dispatch<any>(navigateToRootProject);
     };
 
 export const openUserCreateDialog = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = await services.authService.getUuid();
+        const userUuid = getUserUuid(getState());
+        if (!userUuid) { return; }
         const user = await services.userService.get(userUuid!);
         const virtualMachines = await services.virtualMachineService.list();
         dispatch(reset(USER_CREATE_FORM_NAME));
@@ -109,7 +110,12 @@ export const toggleIsActive = (uuid: string) =>
         const { resources } = getState();
         const data = getResource<UserResource>(uuid)(resources);
         const isActive = data!.isActive;
-        const newActivity = await services.userService.update(uuid, { ...data, isActive: !isActive });
+        let newActivity;
+        if (isActive) {
+            newActivity = await services.userService.unsetup(uuid);
+        } else {
+            newActivity = await services.userService.update(uuid, { isActive: true });
+        }
         dispatch<any>(loadUsersPanel());
         return newActivity;
     };
@@ -119,7 +125,7 @@ export const toggleIsAdmin = (uuid: string) =>
         const { resources } = getState();
         const data = getResource<UserResource>(uuid)(resources);
         const isAdmin = data!.isAdmin;
-        const newActivity = await services.userService.update(uuid, { ...data, isAdmin: !isAdmin });
+        const newActivity = await services.userService.update(uuid, { isAdmin: !isAdmin });
         dispatch<any>(loadUsersPanel());
         return newActivity;
     };
index 799cffa034f01f026aedfc5c6ef52e71ff6d2d59..ceef5e6c79665f9bdfde98faeadbff126a9aecae 100644 (file)
@@ -5,7 +5,7 @@
 import { Dispatch } from 'redux';
 import { ServiceRepository } from '~/services/services';
 import { propertiesActions } from '~/store/properties/properties-actions';
-import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selctors';
+import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selectors';
 import { isVocabulary } from '~/models/vocabulary';
 
 export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
similarity index 95%
rename from src/store/vocabulary/vocabulary-selctors.ts
rename to src/store/vocabulary/vocabulary-selectors.ts
index d317cb4786e4babb6db6242916197a27aaabae73..39f5a01ec86e78786345e0411209df3e97b6833f 100644 (file)
@@ -8,7 +8,7 @@ import { Vocabulary } from '~/models/vocabulary';
 export const VOCABULARY_PROPERTY_NAME = 'vocabulary';
 
 export const DEFAULT_VOCABULARY: Vocabulary = {
-    strict: false,
+    strict_tags: false,
     tags: {},
 };
 
index 868ed5ebfbf2732bebc10a7b0d41120d6bb775d1..81e84ac52951542b2b5647e538a7ec637a5ac342 100644 (file)
@@ -4,6 +4,7 @@
 
 import { Dispatch } from 'redux';
 import { RootState } from "~/store/store";
+import { getUserUuid } from "~/common/getuser"; 
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { favoritePanelActions, loadFavoritePanel } from '~/store/favorite-panel/favorite-panel-action';
@@ -19,7 +20,7 @@ import {
     loadSidePanelTreeProjects,
     SidePanelTreeCategory
 } from '~/store/side-panel-tree/side-panel-tree-actions';
-import { loadResource, updateResources } from '~/store/resources/resources-actions';
+import { updateResources } from '~/store/resources/resources-actions';
 import { projectPanelColumns } from '~/views/project-panel/project-panel';
 import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel';
 import { matchRootRoute } from '~/routes/routes';
@@ -177,7 +178,7 @@ export const loadTrash = () =>
 export const loadProject = (uuid: string) =>
     handleFirstTimeLoad(
         async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-            const userUuid = services.authService.getUuid();
+            const userUuid = getUserUuid(getState());
             dispatch(setIsProjectPanelTrashed(false));
             if (userUuid) {
                 if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) {
@@ -260,7 +261,7 @@ export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialog
 export const loadCollection = (uuid: string) =>
     handleFirstTimeLoad(
         async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-            const userUuid = services.authService.getUuid();
+            const userUuid = getUserUuid(getState());
             if (userUuid) {
                 const match = await loadGroupContentsResource({ uuid, userUuid, services });
                 match({
@@ -306,15 +307,19 @@ export const createCollection = (data: collectionCreateActions.CollectionCreateF
 
 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,
-                kind: SnackbarKind.SUCCESS
-            }));
-            dispatch<any>(updateResources([collection]));
-            dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+        try {
+            const collection = await dispatch<any>(collectionUpdateActions.updateCollection(data));
+            if (collection) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Collection has been successfully updated.",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+                dispatch<any>(updateResources([collection]));
+                dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
+            }
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors.join(''), hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
index 9bde5f949e67b9be0c2b0d846098363a3bcbaa74..5cbfb8378b079bc54c85f63c289e7fcb07bcd093 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { isInteger, isNumber } from 'lodash';
+import { isNumber } from 'lodash';
 
 const ERROR_MESSAGE = 'This field must be a float';
 
index 922e3e52706093929f04a44641e3276187cdbf78..370aa4c3a2ac939dbe8d957c8e1d7df691c4f22a 100644 (file)
@@ -5,12 +5,6 @@
 export const ERROR_MESSAGE = 'Maximum string length of this field is: ';
 export const DEFAULT_MAX_VALUE = 60;
 
-interface MaxLengthProps {
-    maxLengthValue: number;  
-    defaultErrorMessage: string;
-}
-
-// TODO types for maxLength
 export const maxLength: any = (maxLengthValue = DEFAULT_MAX_VALUE, errorMessage = ERROR_MESSAGE) => {
     return (value: string) => {
         if (value) {
index f636850d5a9f0a13acfe909a3346cd3da2f3882b..fbba02aeb4d8e4127da10f58f12723d7672ef5ae 100644 (file)
@@ -4,11 +4,6 @@
 
 export const ERROR_MESSAGE = 'This field is required.';
 
-interface RequiredProps {
-    value: string;
-}
-
-// TODO types for require
 export const require: any = (value: string) => {
     return value && value.length > 0 ? undefined : ERROR_MESSAGE;
 };
diff --git a/src/validators/valid-name.tsx b/src/validators/valid-name.tsx
new file mode 100644 (file)
index 0000000..468811d
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+
+const ERROR_MESSAGE = "Name cannot be '.' or '..' or contain '/' characters";
+
+export const invalidNamingRules = [/\//, /^\.{1,2}$/];
+
+export const validName = (value: string) => {
+    return invalidNamingRules.find(aRule => value.match(aRule) !== null)
+        ? ERROR_MESSAGE
+        : undefined;
+};
index acef9744311ccd82a5d43dcc482eb0250c47cb60..13ce4e6ac63cf2c8228717bc6254e09b742bcabf 100644 (file)
@@ -6,13 +6,14 @@ import { require } from './require';
 import { maxLength } from './max-length';
 import { isRsaKey } from './is-rsa-key';
 import { isRemoteHost } from "./is-remote-host";
+import { validName } from "./valid-name";
 
 export const TAG_KEY_VALIDATION = [require, maxLength(255)];
 export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
 
-export const PROJECT_NAME_VALIDATION = [require, maxLength(255)];
+export const PROJECT_NAME_VALIDATION = [require, validName, maxLength(255)];
 
-export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
+export const COLLECTION_NAME_VALIDATION = [require, validName, maxLength(255)];
 export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
 export const COLLECTION_PROJECT_VALIDATION = [require];
 
diff --git a/src/views-components/add-session/add-session.tsx b/src/views-components/add-session/add-session.tsx
new file mode 100644 (file)
index 0000000..4628e1c
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RouteProps } from "react-router";
+import * as React from "react";
+import { connect, DispatchProp } from "react-redux";
+import { getUrlParameter } from "~/common/url";
+import { navigateToSiteManager } from "~/store/navigation/navigation-action";
+import { addSession } from "~/store/auth/auth-action-session";
+
+export const AddSession = connect()(
+    class extends React.Component<RouteProps & DispatchProp<any>, {}> {
+        componentDidMount() {
+            const search = this.props.location ? this.props.location.search : "";
+            const apiToken = getUrlParameter(search, 'api_token');
+            const baseURL = getUrlParameter(search, 'baseURL');
+
+            this.props.dispatch(addSession(baseURL, apiToken));
+            this.props.dispatch(navigateToSiteManager);
+        }
+        render() {
+            return <div />;
+        }
+    }
+);
index 7dc62552f9111ea3bf706cf7371d7f016d3bf114..0e66a66df796fcba1849fda286715ee53dc2bb69 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@material-ui/core";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
 import { WithDialogProps } from "~/store/dialog/with-dialog";
 import { withDialog } from '~/store/dialog/with-dialog';
 import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
index fae099d5717b3f12bde13763746d26e3f4c9a944..e11afa7bf3395b587e23c312bf331b69151c8a97 100644 (file)
@@ -5,13 +5,11 @@
 import { RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
-import { authActions, getUserDetails, saveApiToken } from "~/store/auth/auth-action";
+import { saveApiToken } from "~/store/auth/auth-action";
 import { getUrlParameter } from "~/common/url";
 import { AuthService } from "~/services/auth-service/auth-service";
 import { navigateToRootProject, navigateToLinkAccount } from "~/store/navigation/navigation-action";
-import { User } from "~/models/user";
 import { Config } from "~/common/config";
-import { initSessions } from "~/store/auth/auth-action-session";
 import { getAccountLinkData } from "~/store/link-account-panel/link-account-panel-actions";
 
 interface ApiTokenProps {
@@ -26,10 +24,7 @@ export const ApiToken = connect()(
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
             const loadMainApp = this.props.loadMainApp;
-            this.props.dispatch(saveApiToken(apiToken));
-            this.props.dispatch<any>(getUserDetails()).then((user: User) => {
-                this.props.dispatch(initSessions(this.props.authService, this.props.config, user));
-            }).finally(() => {
+            this.props.dispatch<any>(saveApiToken(apiToken)).finally(() => {
                 if (loadMainApp) {
                     if (this.props.dispatch(getAccountLinkData())) {
                         this.props.dispatch(navigateToLinkAccount);
index 41ca63950f977cab61e9499dcb17fe6e648b7b3f..5d9946b4848e01e0bc7d6dd3b8740fca3828b3aa 100644 (file)
@@ -29,7 +29,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     grid: {
         padding: '8px 0 0 0'
-    } 
+    }
 });
 
 interface AttributesComputeNodeDialogDataProps {
@@ -81,35 +81,35 @@ const renderPrimaryInfo = (computeNode: NodeResource, classes: any) => {
 };
 
 const renderInfo = (info: NodeInfo, classes: any) => {
-    const { lastAction, pingSecret, ec2InstanceId, slurmState } = info;
+    const { last_action, ping_secret, ec2_instance_id, slurm_state } = info;
     return (
         <Grid container direction="row" spacing={16} className={classnames([classes.root, classes.grid])}>
             <Grid item xs={5}>Info - Last action</Grid>
-            <Grid item xs={7}>{lastAction || '(none)'}</Grid>
+            <Grid item xs={7}>{last_action || '(none)'}</Grid>
             <Grid item xs={5}>Info - Ping secret</Grid>
-            <Grid item xs={7}>{pingSecret || '(none)'}</Grid>
+            <Grid item xs={7}>{ping_secret || '(none)'}</Grid>
             <Grid item xs={5}>Info - ec2 instance id</Grid>
-            <Grid item xs={7}>{ec2InstanceId || '(none)'}</Grid>
+            <Grid item xs={7}>{ec2_instance_id || '(none)'}</Grid>
             <Grid item xs={5}>Info - Slurm state</Grid>
-            <Grid item xs={7}>{slurmState || '(none)'}</Grid>
+            <Grid item xs={7}>{slurm_state || '(none)'}</Grid>
         </Grid>
     );
 };
 
 const renderProperties = (properties: NodeProperties, classes: any) => {
-    const { totalRamMb, totalCpuCores, totalScratchMb, cloudNode } = properties;
+    const { total_ram_mb, total_cpu_cores, total_scratch_mb, cloud_node } = properties;
     return (
         <Grid container direction="row" spacing={16} className={classnames([classes.root, classes.grid])}>
             <Grid item xs={5}>Properties - Total ram mb</Grid>
-            <Grid item xs={7}>{totalRamMb || '(none)'}</Grid>
+            <Grid item xs={7}>{total_ram_mb || '(none)'}</Grid>
             <Grid item xs={5}>Properties - Total scratch mb</Grid>
-            <Grid item xs={7}>{totalScratchMb || '(none)'}</Grid>
+            <Grid item xs={7}>{total_scratch_mb || '(none)'}</Grid>
             <Grid item xs={5}>Properties - Total cpu cores</Grid>
-            <Grid item xs={7}>{totalCpuCores || '(none)'}</Grid>
+            <Grid item xs={7}>{total_cpu_cores || '(none)'}</Grid>
             <Grid item xs={5}>Properties - Cloud node size </Grid>
-            <Grid item xs={7}>{cloudNode ? cloudNode.size : '(none)'}</Grid>
+            <Grid item xs={7}>{cloud_node ? cloud_node.size : '(none)'}</Grid>
             <Grid item xs={5}>Properties - Cloud node price</Grid>
-            <Grid item xs={7}>{cloudNode ? cloudNode.price : '(none)'}</Grid>
+            <Grid item xs={7}>{cloud_node ? cloud_node.price : '(none)'}</Grid>
         </Grid>
     );
 };
\ No newline at end of file
index fe77b749226b4c44de2af1a32e2dad0f9650deb0..9629f028283f016fbece418e36e4df5529dc13e8 100644 (file)
@@ -5,7 +5,7 @@
 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 { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, AdvancedIcon } from "~/components/icon/icon";
 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';
index 4b1d8fd84400697202efaeb6c0476ddba77e0d9b..5e367906cee3072c899e2d43bf15de16db9ab649 100644 (file)
@@ -6,13 +6,12 @@ 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, AdvancedIcon } from '~/components/icon/icon';
+import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, AdvancedIcon } from '~/components/icon/icon';
 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 { toggleCollectionTrashed } from "~/store/trash/trash-actions";
-import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
 import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab';
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
index ed52696859210884b2cebaff37a525354a3e5b55..ae276094bdc50d3a07eea18a2456ed747d84328f 100644 (file)
@@ -6,7 +6,7 @@ import { ContextMenuActionSet } from "~/views-components/context-menu/context-me
 import { ToggleFavoriteAction } from "~/views-components/context-menu/actions/favorite-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import {
-    RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon,
+    RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon,
     AdvancedIcon, RemoveIcon, ReRunProcessIcon, LogIcon, InputIcon, CommandIcon, OutputIcon
 } from "~/components/icon/icon";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
index cc311248cb1d9b0d3f208831604faf75fddd774c..8d2713f5689012c273cb10e4f337e08965a05fa4 100644 (file)
@@ -19,13 +19,14 @@ import { compose, Dispatch } from 'redux';
 import { WorkflowResource } from '~/models/workflow';
 import { ResourceStatus } from '~/views/workflow-panel/workflow-panel-view';
 import { getUuidPrefix, openRunProcess } from '~/store/workflow-panel/workflow-panel-actions';
-import { getResourceData } from "~/store/resources-data/resources-data";
 import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
 import { UserResource } from '~/models/user';
 import { toggleIsActive, toggleIsAdmin } from '~/store/users/users-actions';
 import { LinkResource } from '~/models/link';
 import { navigateTo } from '~/store/navigation/navigation-action';
 import { withResourceData } from '~/views-components/data-explorer/with-resources';
+import { CollectionResource } from '~/models/collection';
+import { IllegalNamingWarning } from '~/components/warning/warning';
 
 const renderName = (dispatch: Dispatch, item: { name: string; uuid: string, kind: string }) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
@@ -34,6 +35,9 @@ const renderName = (dispatch: Dispatch, item: { name: string; uuid: string, kind
         </Grid>
         <Grid item>
             <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
+                { item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION
+                    ? <IllegalNamingWarning name={item.name} />
+                    : null }
                 {item.name}
             </Typography>
         </Grid>
@@ -235,17 +239,19 @@ const clusterColors = [
 
 export const ResourceCluster = (props: { uuid: string }) => {
     const CLUSTER_ID_LENGTH = 5;
-    const pos = props.uuid.indexOf('-');
+    const pos = props.uuid.length > CLUSTER_ID_LENGTH ? props.uuid.indexOf('-') : 5;
     const clusterId = pos >= CLUSTER_ID_LENGTH ? props.uuid.substr(0, pos) : '';
-    const ci = pos >= CLUSTER_ID_LENGTH ? (props.uuid.charCodeAt(0) + props.uuid.charCodeAt(1)) % clusterColors.length : 0;
-    return <Typography>
-        <span style={{
-            backgroundColor: clusterColors[ci][0],
-            color: clusterColors[ci][1],
-            padding: "2px 7px",
-            borderRadius: 3
-        }}>{clusterId}</span>
-    </Typography>;
+    const ci = pos >= CLUSTER_ID_LENGTH ? (((((
+        (props.uuid.charCodeAt(0) * props.uuid.charCodeAt(1))
+        + props.uuid.charCodeAt(2))
+        * props.uuid.charCodeAt(3))
+        + props.uuid.charCodeAt(4))) % clusterColors.length) : 0;
+    return <span style={{
+        backgroundColor: clusterColors[ci][0],
+        color: clusterColors[ci][1],
+        padding: "2px 7px",
+        borderRadius: 3
+    }}>{clusterId}</span>;
 };
 
 export const ComputeNodeInfo = withResourceData('info', renderNodeInfo);
@@ -396,8 +402,8 @@ export const renderFileSize = (fileSize?: number) =>
 
 export const ResourceFileSize = connect(
     (state: RootState, props: { uuid: string }) => {
-        const resource = getResourceData(props.uuid)(state.resourcesData);
-        return { fileSize: resource ? resource.fileSize : 0 };
+        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+        return { fileSize: resource ? resource.fileSizeTotal : 0 };
     })((props: { fileSize?: number }) => renderFileSize(props.fileSize));
 
 const renderOwner = (owner: string) =>
index a523d6fca8e1176c438779c9fee33493add23e55..999d4c79ffe2d5f240ab69af47debe1ff605ec71 100644 (file)
@@ -27,8 +27,8 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
             <DetailsAttribute label='Collection UUID' linkToUuid={this.item.uuid} value={this.item.uuid} />
             <DetailsAttribute label='Content address' linkToUuid={this.item.portableDataHash} value={this.item.portableDataHash} />
             {/* Missing attrs */}
-            <DetailsAttribute label='Number of files' value={this.data && this.data.fileCount} />
-            <DetailsAttribute label='Content size' value={formatFileSize(this.data && this.data.fileSize)} />
+            <DetailsAttribute label='Number of files' value={this.item.fileCount} />
+            <DetailsAttribute label='Content size' value={formatFileSize(this.item.fileSizeTotal)} />
         </div>;
     }
 }
index 45afb02b5a4a562b41a90cd94fcca3c8c3c9eb4e..ca8e2cd7dd86d661af91500515add9b809dcaf13 100644 (file)
@@ -4,10 +4,9 @@
 
 import * as React from 'react';
 import { DetailsResource } from "~/models/details";
-import { ResourceData } from "~/store/resources-data/resources-data-reducer";
 
 export abstract class DetailsData<T extends DetailsResource = DetailsResource> {
-    constructor(protected item: T, protected data?: ResourceData) {}
+    constructor(protected item: T) { }
 
     getTitle(): string {
         return this.item.name || 'Projects';
@@ -17,6 +16,6 @@ export abstract class DetailsData<T extends DetailsResource = DetailsResource> {
     abstract getDetails(): React.ReactElement<any>;
 
     getActivity(): React.ReactElement<any> {
-        return <div/>;
+        return <div />;
     }
 }
index f4aaa8436f753face5dd8bf97a5a80eaea85f134..8244e15f06473f1d472177708830909077db20c8 100644 (file)
@@ -21,8 +21,6 @@ import { EmptyDetails } from "./empty-details";
 import { DetailsData } from "./details-data";
 import { DetailsResource } from "~/models/details";
 import { getResource } from '~/store/resources/resources';
-import { ResourceData } from "~/store/resources-data/resources-data-reducer";
-import { getResourceData } from "~/store/resources-data/resources-data";
 import { toggleDetailsPanel, SLIDE_TIMEOUT } from '~/store/details-panel/details-panel-action';
 import { FileDetails } from '~/views-components/details-panel/file-details';
 import { getNode } from '~/models/tree';
@@ -62,13 +60,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 const EMPTY_RESOURCE: EmptyResource = { kind: undefined, name: 'Projects' };
 
-const getItem = (res: DetailsResource, resourceData?: ResourceData): DetailsData => {
+const getItem = (res: DetailsResource): DetailsData => {
     if ('kind' in res) {
         switch (res.kind) {
             case ResourceKind.PROJECT:
                 return new ProjectDetails(res);
             case ResourceKind.COLLECTION:
-                return new CollectionDetails(res, resourceData);
+                return new CollectionDetails(res);
             case ResourceKind.PROCESS:
                 return new ProcessDetails(res);
             default:
@@ -79,13 +77,12 @@ const getItem = (res: DetailsResource, resourceData?: ResourceData): DetailsData
     }
 };
 
-const mapStateToProps = ({ detailsPanel, resources, resourcesData, collectionPanelFiles }: RootState) => {
+const mapStateToProps = ({ detailsPanel, resources, collectionPanelFiles }: RootState) => {
     const resource = getResource(detailsPanel.resourceUuid)(resources) as DetailsResource | undefined;
     const file = getNode(detailsPanel.resourceUuid)(collectionPanelFiles);
-    const resourceData = getResourceData(detailsPanel.resourceUuid)(resourcesData);
     return {
         isOpened: detailsPanel.isOpened,
-        item: getItem(resource || (file && file.value) || EMPTY_RESOURCE, resourceData),
+        item: getItem(resource || (file && file.value) || EMPTY_RESOURCE),
     };
 };
 
index 1ec1c60c04aae40b17ef2712486952c3fbdad8bb..59035da115574b075a4d58eb0ab5d610302046be 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { compose } from 'redux';
 import { connect } from 'react-redux';
 import { openProjectPropertiesDialog } from '~/store/details-panel/details-panel-action';
 import { ProjectIcon, RenameIcon } from '~/components/icon/icon';
@@ -14,8 +13,10 @@ import { resourceLabel } from '~/common/labels';
 import { DetailsData } from "./details-data";
 import { DetailsAttribute } from "~/components/details-attribute/details-attribute";
 import { RichTextEditorLink } from '~/components/rich-text-editor-link/rich-text-editor-link';
-import { withStyles, StyleRulesCallback, Chip, WithStyles } from '@material-ui/core';
+import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
+import { Dispatch } from 'redux';
+import { PropertyChipComponent } from '../resource-properties-form/property-chip';
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
@@ -40,7 +41,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
-
 interface ProjectDetailsComponentDataProps {
     project: ProjectResource;
 }
@@ -49,7 +49,9 @@ interface ProjectDetailsComponentActionProps {
     onClick: () => void;
 }
 
-const mapDispatchToProps = ({ onClick: openProjectPropertiesDialog });
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: () => dispatch<any>(openProjectPropertiesDialog()),
+});
 
 type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
 
@@ -80,9 +82,11 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                 </div>
             </DetailsAttribute>
             {
-                Object.keys(project.properties).map(k => {
-                    return <Chip key={k} className={classes.tag} label={`${k}: ${project.properties[k]}`} />;
-                })
+                Object.keys(project.properties).map(k =>
+                    <PropertyChipComponent key={k}
+                        propKey={k} propValue={project.properties[k]}
+                        className={classes.tag} />
+                )
             }
         </div>
     ));
index de8a321cf695183ef17b435887469c183bce12f2..2a95f2eb11c73d500be9be8b48bea6f21419a401 100644 (file)
@@ -7,7 +7,7 @@ import { memoize } from 'lodash/fp';
 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 { ProjectTreePickerField } from '~/views-components/projects-tree-picker/tree-picker-field';
 import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from '~/validators/validators';
 import { TextField } from "~/components/text-field/text-field";
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
@@ -34,6 +34,6 @@ const CopyDialogFields = memoize((pickerId: string) =>
             <Field
                 name="ownerUuid"
                 component={ProjectTreePickerField}
-                validate={COPY_FILE_VALIDATION} 
+                validate={COPY_FILE_VALIDATION}
                 pickerId={pickerId}/>
         </span>);
index 785be787f92952daaa606a77a1d51bda436f9d27..374b070b22e914f6c9865ab0ca4a770b4b327716 100644 (file)
@@ -14,7 +14,9 @@ export const CreateCollectionDialog = compose(
     reduxForm<CollectionCreateFormDialogData>({
         form: COLLECTION_CREATE_FORM_NAME,
         onSubmit: (data, dispatch) => {
-            dispatch(createCollection(data));
+            // Somehow an extra field called 'files' gets added, copy
+            // the data object to get rid of it.
+            dispatch(createCollection({ ownerUuid: data.ownerUuid, name: data.name, description: data.description }));
         }
     })
 )(DialogCollectionCreate);
index c962522f3cf8292853bd743d08cfe92f55c18d7d..7f96f478da90a54bcf9008650d1deca412f174d8 100644 (file)
@@ -7,7 +7,7 @@ import { memoize } from 'lodash/fp';
 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 { ProjectTreePickerField } from '~/views-components/projects-tree-picker/tree-picker-field';
 import { MOVE_TO_VALIDATION } from '~/validators/validators';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { PickerIdProp } from "~/store/tree-picker/picker-id";
index 2ebcf08ef323e19e48e7af2d566d96f224448e89..f6dc5d5545e4c13d284578221ccb847c7e8872c3 100644 (file)
@@ -6,7 +6,7 @@ import * as React from "react";
 import { Field } 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 { ProjectTreePickerField, CollectionTreePickerField } from "~/views-components/project-tree-picker/project-tree-picker";
+import { ProjectTreePickerField, CollectionTreePickerField } from "~/views-components/projects-tree-picker/tree-picker-field";
 import { PickerIdProp } from '~/store/tree-picker/picker-id';
 
 export const CollectionNameField = () =>
index 8de48ea744a8494ae2575a5ee51358a0c591902f..837f13cb548b3144e8677f086030665cd02a366f 100644 (file)
@@ -3,20 +3,18 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Field, WrappedFieldProps, FieldArray, formValues } from 'redux-form';
+import { Field, WrappedFieldProps, FieldArray } from 'redux-form';
 import { TextField, DateTextField } from "~/components/text-field/text-field";
 import { CheckboxField } from '~/components/checkbox-field/checkbox-field';
 import { NativeSelectField } from '~/components/select-field/select-field';
 import { ResourceKind } from '~/models/resource';
 import { HomeTreePicker } from '~/views-components/projects-tree-picker/home-tree-picker';
-import { SEARCH_BAR_ADVANCE_FORM_PICKER_ID } from '~/store/search-bar/search-bar-actions';
+import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from '~/store/search-bar/search-bar-actions';
 import { SearchBarAdvancedPropertiesView } from '~/views-components/search-bar/search-bar-advanced-properties-view';
 import { TreeItem } from "~/components/tree/tree";
 import { ProjectsTreePickerItem } from "~/views-components/projects-tree-picker/generic-projects-tree-picker";
-import { PropertyKeyInput } from '~/views-components/resource-properties-form/property-key-field';
-import { PropertyValueInput, PropertyValueFieldProps } from '~/views-components/resource-properties-form/property-value-field';
-import { VocabularyProp, connectVocabulary } from '~/views-components/resource-properties-form/property-field-common';
-import { compose } from 'redux';
+import { PropertyKeyField, } from '~/views-components/resource-properties-form/property-key-field';
+import { PropertyValueField } from '~/views-components/resource-properties-form/property-value-field';
 import { connect } from "react-redux";
 import { RootState } from "~/store/store";
 
@@ -59,7 +57,7 @@ export const SearchBarProjectField = () =>
 const ProjectsPicker = (props: WrappedFieldProps) =>
     <div style={{ height: '100px', display: 'flex', flexDirection: 'column', overflow: 'overlay' }}>
         <HomeTreePicker
-            pickerId={SEARCH_BAR_ADVANCE_FORM_PICKER_ID}
+            pickerId={SEARCH_BAR_ADVANCED_FORM_PICKER_ID}
             toggleItemActive={
                 (_: any, { id }: TreeItem<ProjectsTreePickerItem>) => {
                     props.input.onChange(id);
@@ -88,22 +86,11 @@ export const SearchBarPropertiesField = () =>
         name="properties"
         component={SearchBarAdvancedPropertiesView} />;
 
-export const SearchBarKeyField = connectVocabulary(
-    ({ vocabulary }: VocabularyProp) =>
-        <Field
-            name='key'
-            component={PropertyKeyInput}
-            vocabulary={vocabulary} />);
+export const SearchBarKeyField = () =>
+    <PropertyKeyField skipValidation={true} />;
 
-export const SearchBarValueField = compose(
-    connectVocabulary,
-    formValues({ propertyKey: 'key' })
-)(
-    (props: PropertyValueFieldProps) =>
-        <Field
-            name='value'
-            component={PropertyValueInput}
-            {...props} />);
+export const SearchBarValueField = () =>
+    <PropertyValueField skipValidation={true} />;
 
 export const SearchBarSaveSearchField = () =>
     <Field
index 1b8424c040b296b43db2b388c21368b8f76b7628..346a9ef02ea529db2fa33d4e5664ed7a567ddf40 100644 (file)
@@ -7,7 +7,6 @@ import { MenuItem, Divider } from "@material-ui/core";
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { User, getUserFullname } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
-import { Link } from "react-router-dom";
 import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
 import { logout } from '~/store/auth/auth-action';
@@ -33,7 +32,7 @@ interface AccountMenuProps {
 const mapStateToProps = (state: RootState): AccountMenuProps => ({
     user: state.auth.user,
     currentRoute: state.router.location ? state.router.location.pathname : '',
-    workbenchURL: state.config.workbenchUrl,
+    workbenchURL: state.auth.config.workbenchUrl,
     apiToken: state.auth.apiToken,
     localCluster: state.auth.localCluster
 });
index 9b94c064ed919dc3ed28755456ca729fb4508a27..549ab7ce34609c4040714450d75e1f5e1ca0d480 100644 (file)
@@ -8,7 +8,6 @@ import { User } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { AdminMenuIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
-import { logout } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
 import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
 import * as NavigationAction from '~/store/navigation/navigation-action';
index 15329a43e9119850a8fce69b759319c7f5f69015..be280675c44dda97f5a612ccbe775bc38d2b5f66 100644 (file)
@@ -11,6 +11,6 @@ export const AnonymousMenu = connect()(
     ({ dispatch }: DispatchProp<any>) =>
         <Button
             color="inherit"
-            onClick={() => dispatch(login("", "", {}))}>
+            onClick={() => dispatch(login("", "", "", {}))}>
             Sign in
         </Button>);
index 475b29e1254f1ffcb0cdc6d720e30074bc5c4515..ce1cab4ca01b0b985554d0134ad6dcb3a5ef48b2 100644 (file)
@@ -32,6 +32,7 @@ interface MainAppBarDataProps {
     buildInfo?: string;
     children?: ReactNode;
     uuidPrefix: string;
+    siteBanner: string;
 }
 
 export type MainAppBarProps = MainAppBarDataProps & WithStyles<CssRules>;
@@ -44,7 +45,7 @@ export const MainAppBar = withStyles(styles)(
                     <Grid container item xs={3} direction="column" justify="center">
                         <Typography variant='h6' color="inherit" noWrap>
                             <Link to={Routes.ROOT} className={props.classes.link}>
-                                arvados workbench ({props.uuidPrefix})
+                                <span dangerouslySetInnerHTML={{ __html: props.siteBanner }} /> ({props.uuidPrefix})
                             </Link>
                         </Typography>
                         <Typography variant="caption" color="inherit">{props.buildInfo}</Typography>
index edb4bc6835892e81bd7c9bebe5fdd5213da57041..a2d5940777ed4286586517732e80e42e0b807745 100644 (file)
@@ -8,6 +8,8 @@ import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { withDialog } from "~/store/dialog/with-dialog";
 import { PROCESS_INPUT_DIALOG_NAME } from '~/store/processes/process-input-actions';
 import { RunProcessInputsForm } from "~/views/run-process-panel/run-process-inputs-form";
+import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "~/models/process";
+import { getWorkflowInputs } from "~/models/workflow";
 
 export const ProcessInputDialog = withDialog(PROCESS_INPUT_DIALOG_NAME)(
     (props: WithDialogProps<any>) =>
@@ -31,17 +33,18 @@ export const ProcessInputDialog = withDialog(PROCESS_INPUT_DIALOG_NAME)(
         </Dialog>
 );
 
-const getInputs = (data: any) =>
-    data && data.mounts.varLibCwlWorkflowJson ? data.mounts.varLibCwlWorkflowJson.content.graph.filter((a: any) => a.class === 'Workflow')[0].inputs.map((it: any) => (
-        { type: it.type, id: it.id, label: it.label, value: getInputValue(it.id, data.mounts.varLibCwlCwlInputJson.content), disabled: true }
-    )) : [];
-
-const snakeToCamel = (s: string) => {
-    const a = s.split('/');
-    return a[1].replace(/(\_\w)/g, (m: string) => m[1].toUpperCase());
+const getInputs = (data: any) => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return inputs ? inputs.map(
+        (it: any) => (
+            {
+                type: it.type,
+                id: it.id,
+                label: it.label,
+                value: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
+                disabled: true
+            }
+        )
+    ) : [];
 };
-
-export const getInputValue = (id: string, data: any) => {
-    const a = snakeToCamel(id);
-    return data[a];
-};
\ No newline at end of file
index caedd4e6593703970ed47ee047635d924130e496..7a4cfba6c56e5d133c2a61a94101f33c2d01cd3f 100644 (file)
@@ -9,10 +9,11 @@ import { RootState } from '~/store/store';
 import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
 import { ProjectResource } from '~/models/project';
 import { PROJECT_PROPERTIES_DIALOG_NAME, deleteProjectProperty } from '~/store/details-panel/details-panel-action';
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Chip, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { ProjectPropertiesForm } from '~/views-components/project-properties-dialog/project-properties-form';
 import { getResource } from '~/store/resources/resources';
+import { PropertyChipComponent } from "../resource-properties-form/property-chip";
 
 type CssRules = 'tag';
 
@@ -31,13 +32,12 @@ interface ProjectPropertiesDialogActionProps {
     handleDelete: (key: string) => void;
 }
 
-const mapStateToProps = ({ detailsPanel, resources }: RootState): ProjectPropertiesDialogDataProps => {
-    const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
-    return { project };
-};
+const mapStateToProps = ({ detailsPanel, resources, properties }: RootState): ProjectPropertiesDialogDataProps => ({
+    project: getResource(detailsPanel.resourceUuid)(resources) as ProjectResource,
+});
 
 const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionProps => ({
-    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key))
+    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key)),
 });
 
 type ProjectPropertiesDialogProps =  ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
@@ -53,12 +53,12 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro
                 <DialogTitle>Properties</DialogTitle>
                 <DialogContent>
                     <ProjectPropertiesForm />
-                    {project && project.properties && 
-                        Object.keys(project.properties).map(k => {
-                            return <Chip key={k} className={classes.tag}
+                    {project && project.properties &&
+                        Object.keys(project.properties).map(k =>
+                            <PropertyChipComponent
                                 onDelete={() => handleDelete(k)}
-                                label={`${k}: ${project.properties[k]}`} />;
-                        })
+                                key={k} className={classes.tag}
+                                propKey={k} propValue={project.properties[k]} />)
                     }
                 </DialogContent>
                 <DialogActions>
@@ -70,4 +70,5 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro
                     </Button>
                 </DialogActions>
             </Dialog>
-)));
\ No newline at end of file
+    )
+));
\ No newline at end of file
diff --git a/src/views-components/project-tree-picker/project-tree-picker.tsx b/src/views-components/project-tree-picker/project-tree-picker.tsx
deleted file mode 100644 (file)
index 215c31b..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-// 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 { Typography } from "@material-ui/core";
-import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
-import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
-import { ProjectResource } from "~/models/project";
-import { treePickerActions, loadProjectTreePickerProjects, loadFavoriteTreePickerProjects, loadPublicFavoriteTreePickerProjects } from "~/store/tree-picker/tree-picker-actions";
-import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, PublicFavoriteIcon } from '~/components/icon/icon';
-import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
-import { WrappedFieldProps } from 'redux-form';
-import { TreePickerId } from '~/models/tree';
-import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
-import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
-import { PickerIdProp } from '~/store/tree-picker/picker-id';
-
-type ProjectTreePickerProps = Pick<TreePickerProps<ProjectResource>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
-
-const mapDispatchToProps = (dispatch: Dispatch, props: { onChange: (projectUuid: string) => void }): ProjectTreePickerProps => ({
-    onContextMenu: () => { return; },
-    toggleItemActive: (_, { id }, pickerId) => {
-        getNotSelectedTreePickerKind(pickerId)
-            .forEach(pickerId => dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: '', pickerId })));
-        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId }));
-
-        props.onChange(id);
-    },
-    toggleItemOpen: (_, { id, status }, pickerId) => {
-        dispatch<any>(toggleItemOpen(id, status, pickerId));
-    },
-    toggleItemSelection: (_, { id }, pickerId) => {
-        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id, pickerId }));
-    },
-});
-
-const toggleItemOpen = (id: string, status: TreeItemStatus, pickerId: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        if (status === TreeItemStatus.INITIAL) {
-            if (pickerId === TreePickerId.PROJECTS) {
-                dispatch<any>(loadProjectTreePickerProjects(id));
-            } else if (pickerId === TreePickerId.FAVORITES) {
-                dispatch<any>(loadFavoriteTreePickerProjects(id === services.authService.getUuid() ? '' : id));
-            } else if (pickerId === TreePickerId.PUBLIC_FAVORITES) {
-                dispatch<any>(loadPublicFavoriteTreePickerProjects(id === services.authService.getUuid() ? '' : id));
-                // TODO: load sharedWithMe
-            }
-        } else {
-            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
-        }
-    };
-
-const getNotSelectedTreePickerKind = (pickerId: string) => {
-    return [TreePickerId.PROJECTS, TreePickerId.FAVORITES, TreePickerId.SHARED_WITH_ME].filter(nodeId => nodeId !== pickerId);
-};
-
-export const ProjectTreePicker = connect(undefined, mapDispatchToProps)((props: ProjectTreePickerProps) =>
-    <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} pickerId={TreePickerId.PROJECTS} />
-            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.SHARED_WITH_ME} />
-            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.FAVORITES} />
-            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.PUBLIC_FAVORITES} />
-        </div>
-    </div>);
-
-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;
-        case TreePickerId.PUBLIC_FAVORITES:
-            return PublicFavoriteIcon;
-        default:
-            return ProjectIcon;
-    }
-};
-
-const renderTreeItem = (item: TreeItem<ProjectResource>) =>
-    <ListItemTextIcon
-        icon={getProjectPickerIcon(item)}
-        name={typeof item.data === 'string' ? item.data : item.data.name}
-        isActive={item.active}
-        hasMargin={true} />;
-
-export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
-    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectsTreePicker
-            pickerId={props.pickerId}
-            toggleItemActive={handleChange(props)} />
-        {props.meta.dirty && props.meta.error &&
-            <Typography variant='caption' color='error'>
-                {props.meta.error}
-            </Typography>}
-    </div>;
-
-const handleChange = (props: WrappedFieldProps) =>
-    (_: any, { id }: TreeItem<ProjectsTreePickerItem>) =>
-        props.input.onChange(id);
-
-export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
-    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectsTreePicker
-            pickerId={props.pickerId}
-            toggleItemActive={handleChange(props)}
-            includeCollections />
-        {props.meta.dirty && props.meta.error &&
-            <Typography variant='caption' color='error'>
-                {props.meta.error}
-            </Typography>}
-    </div>;
\ No newline at end of file
diff --git a/src/views-components/project-tree/project-tree.test.tsx b/src/views-components/project-tree/project-tree.test.tsx
deleted file mode 100644 (file)
index 18efdaf..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import * as Enzyme from 'enzyme';
-import { mount } from 'enzyme';
-import * as Adapter from 'enzyme-adapter-react-16';
-import ListItemIcon from '@material-ui/core/ListItemIcon';
-import { Collapse } from '@material-ui/core';
-import CircularProgress from '@material-ui/core/CircularProgress';
-
-import { ProjectTree } from './project-tree';
-import { TreeItem, TreeItemStatus } from '../../components/tree/tree';
-import { ProjectResource } from '../../models/project';
-import { mockProjectResource } from '../../models/test-utils';
-
-Enzyme.configure({ adapter: new Adapter() });
-
-describe("ProjectTree component", () => {
-
-    it("should render ListItemIcon", () => {
-        const project: TreeItem<ProjectResource> = {
-            data: mockProjectResource(),
-            id: "3",
-            open: true,
-            active: true,
-            status: TreeItemStatus.PENDING
-        };
-        const wrapper = mount(<ProjectTree
-            projects={[project]}
-            toggleOpen={jest.fn()}
-            toggleActive={jest.fn()}
-            onContextMenu={jest.fn()} />);
-
-        expect(wrapper.find(ListItemIcon)).toHaveLength(2);
-    });
-
-    it("should render Collapse", () => {
-        const project: Array<TreeItem<ProjectResource>> = [
-            {
-                data: mockProjectResource(),
-                id: "3",
-                open: true,
-                active: true,
-                status: TreeItemStatus.LOADED,
-                items: [
-                    {
-                        data: mockProjectResource(),
-                        id: "3",
-                        open: true,
-                        active: true,
-                        status: TreeItemStatus.PENDING
-                    }
-                ]
-            }
-        ];
-        const wrapper = mount(<ProjectTree
-            projects={project}
-            toggleOpen={jest.fn()}
-            toggleActive={jest.fn()}
-            onContextMenu={jest.fn()} />);
-
-        expect(wrapper.find(Collapse)).toHaveLength(1);
-    });
-
-    it("should render CircularProgress", () => {
-        const project: TreeItem<ProjectResource> = {
-            data: mockProjectResource(),
-            id: "3",
-            open: false,
-            active: true,
-            status: TreeItemStatus.PENDING
-        };
-        const wrapper = mount(<ProjectTree
-            projects={[project]}
-            toggleOpen={jest.fn()}
-            toggleActive={jest.fn()}
-            onContextMenu={jest.fn()} />);
-
-        expect(wrapper.find(CircularProgress)).toHaveLength(1);
-    });
-});
diff --git a/src/views-components/project-tree/project-tree.tsx b/src/views-components/project-tree/project-tree.tsx
deleted file mode 100644 (file)
index fe808af..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { ReactElement } from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { Tree, TreeItem, TreeItemStatus } from '~/components/tree/tree';
-import { ProjectResource } from '~/models/project';
-import { ProjectIcon } from '~/components/icon/icon';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { ListItemTextIcon } from '~/components/list-item-text-icon/list-item-text-icon';
-
-type CssRules = 'root';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        marginLeft: `${theme.spacing.unit * 1.5}px`,
-    }
-});
-
-export interface ProjectTreeProps<T> {
-    projects: Array<TreeItem<ProjectResource>>;
-    toggleOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
-    toggleActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<ProjectResource>) => void;
-}
-
-export const ProjectTree = withStyles(styles)(
-    class ProjectTreeGeneric<T> extends React.Component<ProjectTreeProps<T> & WithStyles<CssRules>> {
-        render(): ReactElement<any> {
-            const { classes, projects, toggleOpen, toggleActive, onContextMenu } = this.props;
-            return (
-                <div className={classes.root}>
-                    <Tree items={projects}
-                        onContextMenu={onContextMenu}
-                        toggleItemOpen={toggleOpen}
-                        toggleItemActive={toggleActive}
-                        render={
-                            (project: TreeItem<ProjectResource>) =>
-                                <ListItemTextIcon
-                                    icon={ProjectIcon}
-                                    name={project.data.name}
-                                    isActive={project.active}
-                                    hasMargin={true} />
-                        } />
-                </div>
-            );
-        }
-    }
-);
index f4969f5cf4e0de16fc3a804c4740f7fb2ac0bfd8..10bb5eb822c411a4236cd6b80cbb052612d555d5 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { values, memoize, pipe, pick } from 'lodash/fp';
+import { values, memoize, pipe } from 'lodash/fp';
 import { HomeTreePicker } from '~/views-components/projects-tree-picker/home-tree-picker';
 import { SharedTreePicker } from '~/views-components/projects-tree-picker/shared-tree-picker';
 import { FavoritesTreePicker } from '~/views-components/projects-tree-picker/favorites-tree-picker';
diff --git a/src/views-components/projects-tree-picker/tree-picker-field.tsx b/src/views-components/projects-tree-picker/tree-picker-field.tsx
new file mode 100644 (file)
index 0000000..a8ab05f
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Typography } from "@material-ui/core";
+import { TreeItem } from "~/components/tree/tree";
+import { WrappedFieldProps } from 'redux-form';
+import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
+import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
+import { PickerIdProp } from '~/store/tree-picker/picker-id';
+
+export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
+    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectsTreePicker
+            pickerId={props.pickerId}
+            toggleItemActive={handleChange(props)} />
+        {props.meta.dirty && props.meta.error &&
+            <Typography variant='caption' color='error'>
+                {props.meta.error}
+            </Typography>}
+    </div>;
+
+const handleChange = (props: WrappedFieldProps) =>
+    (_: any, { id }: TreeItem<ProjectsTreePickerItem>) =>
+        props.input.onChange(id);
+
+export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
+    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectsTreePicker
+            pickerId={props.pickerId}
+            toggleItemActive={handleChange(props)}
+            includeCollections />
+        {props.meta.dirty && props.meta.error &&
+            <Typography variant='caption' color='error'>
+                {props.meta.error}
+            </Typography>}
+    </div>;
\ No newline at end of file
diff --git a/src/views-components/resource-properties-form/property-chip.tsx b/src/views-components/resource-properties-form/property-chip.tsx
new file mode 100644 (file)
index 0000000..c51a8d8
--- /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 { Chip } from '@material-ui/core';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selectors';
+import { Dispatch } from 'redux';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getTagValueLabel, getTagKeyLabel, Vocabulary } from '~/models/vocabulary';
+
+interface PropertyChipComponentDataProps {
+    propKey: string;
+    propValue: string;
+    className: string;
+    vocabulary: Vocabulary;
+}
+
+interface PropertyChipComponentActionProps {
+    onDelete?: () => void;
+    onCopy: (message: string) => void;
+}
+
+type PropertyChipComponentProps = PropertyChipComponentActionProps & PropertyChipComponentDataProps;
+
+const mapStateToProps = ({ properties }: RootState) => ({
+    vocabulary: getVocabulary(properties),
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onCopy: (message: string) => dispatch(snackbarActions.OPEN_SNACKBAR({
+        message,
+        hideDuration: 2000,
+        kind: SnackbarKind.SUCCESS
+    }))
+});
+
+// Renders a Chip with copyable-on-click tag:value data based on the vocabulary
+export const PropertyChipComponent = connect(mapStateToProps, mapDispatchToProps)(
+    ({ propKey, propValue, vocabulary, className, onCopy, onDelete }: PropertyChipComponentProps) => {
+        const label = `${getTagKeyLabel(propKey, vocabulary)}: ${getTagValueLabel(propKey, propValue, vocabulary)}`;
+        return (
+            <CopyToClipboard key={propKey} text={label} onCopy={() => onCopy("Copied to clipboard")}>
+                <Chip onDelete={onDelete} key={propKey}
+                    className={className} label={label} />
+            </CopyToClipboard>
+        );
+    }
+);
index 028c46b9d34ab42d8517a4d9270f988823718b8b..e802ad5ccbd8b355b4d362eab0b3065714cfaa7f 100644 (file)
@@ -3,17 +3,21 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from 'react-redux';
-import { WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
-import { identity } from 'lodash';
-import { Vocabulary } from '~/models/vocabulary';
+import { change, WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
+import { Vocabulary, PropFieldSuggestion } from '~/models/vocabulary';
 import { RootState } from '~/store/store';
-import { getVocabulary } from '~/store/vocabulary/vocabulary-selctors';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selectors';
 
 export interface VocabularyProp {
     vocabulary: Vocabulary;
 }
 
-export const mapStateToProps = (state: RootState): VocabularyProp => ({
+export interface ValidationProp {
+    skipValidation?: boolean;
+}
+
+export const mapStateToProps = (state: RootState, ownProps: ValidationProp): VocabularyProp & ValidationProp => ({
+    skipValidation: ownProps.skipValidation,
     vocabulary: getVocabulary(state.properties),
 });
 
@@ -29,17 +33,39 @@ export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
         ? meta.error
         : '';
 
-export const handleBlur = ({ onBlur, value }: WrappedFieldInputProps) =>
-    () =>
+export const buildProps = ({ input, meta }: WrappedFieldProps) => {
+    return {
+        value: input.value,
+        onChange: input.onChange,
+        items: ITEMS_PLACEHOLDER,
+        renderSuggestion: (item: PropFieldSuggestion) => item.label,
+        error: hasError(meta),
+        helperText: getErrorMsg(meta),
+    };
+};
+
+// Attempts to match a manually typed value label with a value ID, when the user
+// doesn't select the value from the suggestions list.
+export const handleBlur = (
+    fieldName: string,
+    formName: string,
+    { dispatch }: WrappedFieldMetaProps,
+    { onBlur, value }: WrappedFieldInputProps,
+    fieldValue: string) =>
+    () => {
+        dispatch(change(formName, fieldName, fieldValue));
         onBlur(value);
+    };
 
-export const buildProps = ({ input, meta }: WrappedFieldProps) => ({
-    value: input.value,
-    onChange: input.onChange,
-    onBlur: handleBlur(input),
-    items: ITEMS_PLACEHOLDER,
-    onSelect: input.onChange,
-    renderSuggestion: identity,
-    error: hasError(meta),
-    helperText: getErrorMsg(meta),
-});
+// When selecting a property value, save its ID for later usage.
+export const handleSelect = (
+    fieldName: string,
+    formName: string,
+    { onChange }: WrappedFieldInputProps,
+    { dispatch }: WrappedFieldMetaProps) =>
+    (item: PropFieldSuggestion) => {
+        if (item) {
+            onChange(item.label);
+            dispatch(change(formName, fieldName, item.id));
+        }
+    };
index 3fb2d377aeff56574a06a919f2f33e3aea5f142e..1f92118885690992b19cb6c81e5a88eaea47c959 100644 (file)
@@ -3,44 +3,50 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { WrappedFieldProps, Field } from 'redux-form';
+import { WrappedFieldProps, Field, FormName } from 'redux-form';
 import { memoize } from 'lodash';
 import { Autocomplete } from '~/components/autocomplete/autocomplete';
-import { Vocabulary } from '~/models/vocabulary';
-import { connectVocabulary, VocabularyProp, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { Vocabulary, getTags, getTagKeyID } from '~/models/vocabulary';
+import { handleSelect, handleBlur, connectVocabulary, VocabularyProp, ValidationProp, buildProps } from '~/views-components/resource-properties-form/property-field-common';
 import { TAG_KEY_VALIDATION } from '~/validators/validators';
+import { escapeRegExp } from '~/common/regexp.ts';
 
 export const PROPERTY_KEY_FIELD_NAME = 'key';
+export const PROPERTY_KEY_FIELD_ID = 'keyID';
 
 export const PropertyKeyField = connectVocabulary(
-    ({ vocabulary }: VocabularyProp) =>
+    ({ vocabulary, skipValidation }: VocabularyProp & ValidationProp) =>
         <Field
             name={PROPERTY_KEY_FIELD_NAME}
             component={PropertyKeyInput}
             vocabulary={vocabulary}
-            validate={getValidation(vocabulary)} />);
-
-export const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
-    <Autocomplete
-        label='Key'
-        suggestions={getSuggestions(props.input.value, vocabulary)}
-        {...buildProps(props)}
-    />;
+            validate={skipValidation ? undefined : getValidation(vocabulary)} />
+);
+
+const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
+    <FormName children={data => (
+        <Autocomplete
+            label='Key'
+            suggestions={getSuggestions(props.input.value, vocabulary)}
+            onSelect={handleSelect(PROPERTY_KEY_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={handleBlur(PROPERTY_KEY_FIELD_ID, data.form, props.meta, props.input, getTagKeyID(props.input.value, vocabulary))}
+            {...buildProps(props)}
+        />
+    )} />;
 
 const getValidation = memoize(
     (vocabulary: Vocabulary) =>
-        vocabulary.strict
+        vocabulary.strict_tags
             ? [...TAG_KEY_VALIDATION, matchTags(vocabulary)]
             : TAG_KEY_VALIDATION);
 
 const matchTags = (vocabulary: Vocabulary) =>
     (value: string) =>
-        getTagsList(vocabulary).find(tag => tag.includes(value))
+        getTags(vocabulary).find(tag => tag.label === value)
             ? undefined
             : 'Incorrect key';
 
-const getSuggestions = (value: string, vocabulary: Vocabulary) =>
-    getTagsList(vocabulary).filter(tag => tag.includes(value) && tag !== value);
-
-const getTagsList = ({ tags }: Vocabulary) =>
-    Object.keys(tags);
+const getSuggestions = (value: string, vocabulary: Vocabulary) => {
+    const re = new RegExp(escapeRegExp(value), "i");
+    return getTags(vocabulary).filter(tag => re.test(tag.label) && tag.label !== value);
+};
index 13dcfeb544278acf62db0b41aca9c113333043d3..99745199feebe96b7dad0ca80cb140da4c6853e2 100644 (file)
@@ -3,39 +3,48 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { WrappedFieldProps, Field, formValues } from 'redux-form';
+import { WrappedFieldProps, Field, formValues, FormName } from 'redux-form';
 import { compose } from 'redux';
 import { Autocomplete } from '~/components/autocomplete/autocomplete';
-import { Vocabulary } from '~/models/vocabulary';
-import { PROPERTY_KEY_FIELD_NAME } from '~/views-components/resource-properties-form/property-key-field';
-import { VocabularyProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { Vocabulary, isStrictTag, getTagValues, getTagValueID } from '~/models/vocabulary';
+import { PROPERTY_KEY_FIELD_ID } from '~/views-components/resource-properties-form/property-key-field';
+import { handleSelect, handleBlur, VocabularyProp, ValidationProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
 import { TAG_VALUE_VALIDATION } from '~/validators/validators';
+import { escapeRegExp } from '~/common/regexp.ts';
 
 interface PropertyKeyProp {
     propertyKey: string;
 }
 
-export type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp;
+type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp & ValidationProp;
 
 export const PROPERTY_VALUE_FIELD_NAME = 'value';
+export const PROPERTY_VALUE_FIELD_ID = 'valueID';
 
-export const PropertyValueField = compose(
+const connectVocabularyAndPropertyKey = compose(
     connectVocabulary,
-    formValues({ propertyKey: PROPERTY_KEY_FIELD_NAME })
-)(
-    (props: PropertyValueFieldProps) =>
+    formValues({ propertyKey: PROPERTY_KEY_FIELD_ID }),
+);
+
+export const PropertyValueField = connectVocabularyAndPropertyKey(
+    ({ skipValidation, ...props }: PropertyValueFieldProps) =>
         <Field
             name={PROPERTY_VALUE_FIELD_NAME}
             component={PropertyValueInput}
-            validate={getValidation(props)}
-            {...props} />);
-
-export const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
-    <Autocomplete
-        label='Value'
-        suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
-        {...buildProps(props)}
-    />;
+            validate={skipValidation ? undefined : getValidation(props)}
+            {...props} />
+);
+
+const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
+    <FormName children={data => (
+        <Autocomplete
+            label='Value'
+            suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
+            onSelect={handleSelect(PROPERTY_VALUE_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={handleBlur(PROPERTY_VALUE_FIELD_ID, data.form, props.meta, props.input, getTagValueID(propertyKey, props.input.value, vocabulary))}
+            {...buildProps(props)}
+        />
+    )} />;
 
 const getValidation = (props: PropertyValueFieldProps) =>
     isStrictTag(props.propertyKey, props.vocabulary)
@@ -44,19 +53,11 @@ const getValidation = (props: PropertyValueFieldProps) =>
 
 const matchTagValues = ({ vocabulary, propertyKey }: PropertyValueFieldProps) =>
     (value: string) =>
-        getTagValues(propertyKey, vocabulary).find(v => v.includes(value))
+        getTagValues(propertyKey, vocabulary).find(v => v.label === value)
             ? undefined
             : 'Incorrect value';
 
-const getSuggestions = (value: string, tagName: string, vocabulary: Vocabulary) =>
-    getTagValues(tagName, vocabulary).filter(v => v.includes(value) && v !== value);
-
-const isStrictTag = (tagName: string, vocabulary: Vocabulary) => {
-    const tag = vocabulary.tags[tagName];
-    return tag ? tag.strict : false;
-};
-
-const getTagValues = (tagName: string, vocabulary: Vocabulary) => {
-    const tag = vocabulary.tags[tagName];
-    return tag && tag.values ? tag.values : [];
+const getSuggestions = (value: string, tagName: string, vocabulary: Vocabulary) => {
+    const re = new RegExp(escapeRegExp(value), "i");
+    return getTagValues(tagName, vocabulary).filter(v => re.test(v.label) && v.label !== value);
 };
index 6c2e025a0834f85d4c816f492ec717f6f0d164fb..db40e4a7e8718e609e7d13814bf9e6ac638a6946 100644 (file)
@@ -5,14 +5,16 @@
 import * as React from 'react';
 import { InjectedFormProps } from 'redux-form';
 import { Grid, withStyles, WithStyles } from '@material-ui/core';
-import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME } from './property-key-field';
-import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME } from './property-value-field';
+import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME, PROPERTY_KEY_FIELD_ID } from './property-key-field';
+import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME, PROPERTY_VALUE_FIELD_ID } from './property-value-field';
 import { ProgressButton } from '~/components/progress-button/progress-button';
 import { GridClassKey } from '@material-ui/core/Grid';
 
 export interface ResourcePropertiesFormData {
     [PROPERTY_KEY_FIELD_NAME]: string;
+    [PROPERTY_KEY_FIELD_ID]: string;
     [PROPERTY_VALUE_FIELD_NAME]: string;
+    [PROPERTY_VALUE_FIELD_ID]: string;
 }
 
 export type ResourcePropertiesFormProps = InjectedFormProps<ResourcePropertiesFormData> & WithStyles<GridClassKey>;
index d4044f958d55b0d1c270232b32469c0c74fe3559..eb049b7625262dfe5caee013d21681f8df3bc0ae 100644 (file)
@@ -3,21 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Dispatch } from 'redux';
+import { Dispatch, compose } from 'redux';
 import { connect } from 'react-redux';
 import { InjectedFormProps, formValueSelector } from 'redux-form';
 import { Grid, withStyles, StyleRulesCallback, WithStyles, Button } from '@material-ui/core';
 import { RootState } from '~/store/store';
 import {
-    SEARCH_BAR_ADVANCE_FORM_NAME,
-    changeAdvanceFormProperty,
-    updateAdvanceFormProperties
+    SEARCH_BAR_ADVANCED_FORM_NAME,
+    changeAdvancedFormProperty,
+    resetAdvancedFormProperty
 } from '~/store/search-bar/search-bar-actions';
 import { PropertyValue } from '~/models/search-bar';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { SearchBarKeyField, SearchBarValueField } from '~/views-components/form-fields/search-bar-form-fields';
 import { Chips } from '~/components/chips/chips';
 import { formatPropertyValue } from "~/common/formatters";
+import { Vocabulary } from '~/models/vocabulary';
+import { connectVocabulary } from '../resource-properties-form/property-field-common';
 
 type CssRules = 'label' | 'button';
 
@@ -38,11 +40,12 @@ interface SearchBarAdvancedPropertiesViewDataProps {
     pristine: boolean;
     propertyValues: PropertyValue;
     fields: PropertyValue[];
+    vocabulary: Vocabulary;
 }
 
 interface SearchBarAdvancedPropertiesViewActionProps {
     setProps: () => void;
-    addProp: (propertyValues: PropertyValue) => void;
+    setProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
     getAllFields: (propertyValues: PropertyValue[]) => PropertyValue[] | [];
 }
 
@@ -50,30 +53,37 @@ type SearchBarAdvancedPropertiesViewProps = SearchBarAdvancedPropertiesViewDataP
     & SearchBarAdvancedPropertiesViewActionProps
     & InjectedFormProps & WithStyles<CssRules>;
 
-const selector = formValueSelector(SEARCH_BAR_ADVANCE_FORM_NAME);
+const selector = formValueSelector(SEARCH_BAR_ADVANCED_FORM_NAME);
 const mapStateToProps = (state: RootState) => {
     return {
-        propertyValues: selector(state, 'key', 'value')
+        propertyValues: selector(state, 'key', 'value', 'keyID', 'valueID')
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     setProps: (propertyValues: PropertyValue[]) => {
-        dispatch<any>(changeAdvanceFormProperty('properties', propertyValues));
+        dispatch<any>(changeAdvancedFormProperty('properties', propertyValues));
     },
-    addProp: (propertyValues: PropertyValue) => {
-        dispatch<any>(updateAdvanceFormProperties(propertyValues));
-        dispatch<any>(changeAdvanceFormProperty('key'));
-        dispatch<any>(changeAdvanceFormProperty('value'));
+    setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+        dispatch<any>(changeAdvancedFormProperty(
+            'properties',
+            [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue]
+        ));
+        dispatch<any>(resetAdvancedFormProperty('key'));
+        dispatch<any>(resetAdvancedFormProperty('value'));
+        dispatch<any>(resetAdvancedFormProperty('keyID'));
+        dispatch<any>(resetAdvancedFormProperty('valueID'));
     },
     getAllFields: (fields: any) => {
         return fields.getAll() || [];
     }
 });
 
-export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispatchToProps)(
+export const SearchBarAdvancedPropertiesView = compose(
+    connectVocabulary,
+    connect(mapStateToProps, mapDispatchToProps))(
     withStyles(styles)(
-        ({ classes, fields, propertyValues, setProps, addProp, getAllFields }: SearchBarAdvancedPropertiesViewProps) =>
+        ({ classes, fields, propertyValues, setProps, setProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
             <Grid container item xs={12} spacing={16}>
                 <Grid item xs={2} className={classes.label}>Properties</Grid>
                 <Grid item xs={4}>
@@ -83,7 +93,7 @@ export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispa
                     <SearchBarValueField />
                 </Grid>
                 <Grid container item xs={2} justify='flex-end' alignItems="center">
-                    <Button className={classes.button} onClick={() => addProp(propertyValues)}
+                    <Button className={classes.button} onClick={() => setProp(propertyValues, getAllFields(fields))}
                         color="primary"
                         size='small'
                         variant="contained"
@@ -96,7 +106,7 @@ export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispa
                     <Chips values={getAllFields(fields)}
                         deletable
                         onChange={setProps}
-                        getLabel={(field: PropertyValue) => formatPropertyValue(field)} />
+                        getLabel={(field: PropertyValue) => formatPropertyValue(field, vocabulary)} />
                 </Grid>
             </Grid>
     )
index b001cb3eb1f1920e0b08ec1f50ff95e4e9aea1fb..71d32ad7e95dd51efa91ee4794b6e4172154cbc1 100644 (file)
@@ -7,13 +7,13 @@ import { reduxForm, InjectedFormProps, reset } from 'redux-form';
 import { compose, Dispatch } from 'redux';
 import { Paper, StyleRulesCallback, withStyles, WithStyles, Button, Grid, IconButton, CircularProgress } from '@material-ui/core';
 import {
-    SEARCH_BAR_ADVANCE_FORM_NAME, SEARCH_BAR_ADVANCE_FORM_PICKER_ID,
-    searchAdvanceData,
+    SEARCH_BAR_ADVANCED_FORM_NAME, SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
+    searchAdvancedData,
     setSearchValueFromAdvancedData
 } from '~/store/search-bar/search-bar-actions';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { CloseIcon } from '~/components/icon/icon';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import {
     SearchBarTypeField, SearchBarClusterField, SearchBarProjectField, SearchBarTrashField,
     SearchBarDateFromField, SearchBarDateToField, SearchBarPropertiesField,
@@ -100,15 +100,15 @@ const validate = (values: any) => {
 };
 
 export const SearchBarAdvancedView = compose(
-    reduxForm<SearchBarAdvanceFormData, SearchBarAdvancedViewProps>({
-        form: SEARCH_BAR_ADVANCE_FORM_NAME,
+    reduxForm<SearchBarAdvancedFormData, SearchBarAdvancedViewProps>({
+        form: SEARCH_BAR_ADVANCED_FORM_NAME,
         validate,
-        onSubmit: (data: SearchBarAdvanceFormData, dispatch: Dispatch) => {
-            dispatch<any>(searchAdvanceData(data));
-            dispatch(reset(SEARCH_BAR_ADVANCE_FORM_NAME));
-            dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        onSubmit: (data: SearchBarAdvancedFormData, dispatch: Dispatch) => {
+            dispatch<any>(searchAdvancedData(data));
+            dispatch(reset(SEARCH_BAR_ADVANCED_FORM_NAME));
+            dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         },
-        onChange: (data: SearchBarAdvanceFormData, dispatch: Dispatch, props: any, prevData: SearchBarAdvanceFormData) => {
+        onChange: (data: SearchBarAdvancedFormData, dispatch: Dispatch, props: any, prevData: SearchBarAdvancedFormData) => {
             dispatch<any>(setSearchValueFromAdvancedData(data, prevData));
         },
     }),
index b23a96a089e4c26099c6dbeb6cc3460d63c9c657..dd6028a6880545cbd8e979433c45fba7a2777209 100644 (file)
@@ -4,7 +4,6 @@
 
 import * as React from 'react';
 import { Paper, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
-import { SearchView } from '~/store/search-bar/search-bar-reducer';
 import {
     SearchBarRecentQueries,
     SearchBarRecentQueriesActionProps
index 5234c214cb9050d950781bf9224c36c030d9279d..af1c24b41dd5c4e176f7914159871e065f82cad6 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { withStyles, WithStyles, StyleRulesCallback, List, ListItem, ListItemText, ListItemSecondaryAction, Tooltip, IconButton } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RemoveIcon, EditSavedQueryIcon } from '~/components/icon/icon';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import { SearchBarSelectedItem } from "~/store/search-bar/search-bar-reducer";
 import { getQueryFromAdvancedData } from "~/store/search-bar/search-bar-actions";
 
@@ -31,14 +31,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 export interface SearchBarSavedQueriesDataProps {
-    savedQueries: SearchBarAdvanceFormData[];
+    savedQueries: SearchBarAdvancedFormData[];
     selectedItem: SearchBarSelectedItem;
 }
 
 export interface SearchBarSavedQueriesActionProps {
     onSearch: (searchValue: string) => void;
     deleteSavedQuery: (id: number) => void;
-    editSavedQuery: (data: SearchBarAdvanceFormData, id: number) => void;
+    editSavedQuery: (data: SearchBarAdvancedFormData, id: number) => void;
 }
 
 type SearchBarSavedQueriesProps = SearchBarSavedQueriesDataProps
index 176ca018b420eff2a8ef6e3ae951d91cacb1e825..49a8ba6235e5183c2a528287572c2f7081d11dca 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { compose } from 'redux';
 import {
     IconButton,
     Paper,
@@ -33,6 +34,8 @@ import {
 } from '~/views-components/search-bar/search-bar-advanced-view';
 import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "~/common/codes";
 import { debounce } from 'debounce';
+import { Vocabulary } from '~/models/vocabulary';
+import { connectVocabulary } from '../resource-properties-form/property-field-common';
 
 type CssRules = 'container' | 'containerSearchViewOpened' | 'input' | 'view';
 
@@ -72,6 +75,7 @@ interface SearchBarViewDataProps {
     currentView: string;
     isPopoverOpen: boolean;
     debounce?: number;
+    vocabulary?: Vocabulary;
 }
 
 export type SearchBarActionProps = SearchBarViewActionProps
@@ -88,7 +92,7 @@ interface SearchBarViewActionProps {
     loadRecentQueries: () => string[];
     moveUp: () => void;
     moveDown: () => void;
-    setAdvancedDataFromSearchValue: (search: string) => void;
+    setAdvancedDataFromSearchValue: (search: string, vocabulary?: Vocabulary) => void;
 }
 
 type SearchBarViewProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>;
@@ -132,20 +136,15 @@ const handleInputClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
 
 const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
     e.stopPropagation();
-    if (props.isPopoverOpen) {
-        if (props.currentView === SearchView.ADVANCED) {
-            props.closeView();
-        } else {
-            props.setAdvancedDataFromSearchValue(props.searchValue);
-            props.onSetView(SearchView.ADVANCED);
-        }
+    if (props.isPopoverOpen && props.currentView === SearchView.ADVANCED) {
+        props.closeView();
     } else {
-        props.setAdvancedDataFromSearchValue(props.searchValue);
+        props.setAdvancedDataFromSearchValue(props.searchValue, props.vocabulary);
         props.onSetView(SearchView.ADVANCED);
     }
 };
 
-export const SearchBarView = withStyles(styles)(
+export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
     class extends React.Component<SearchBarViewProps> {
 
         debouncedSearch = debounce(() => {
index 41cf291688dc2d53ef7534ee8048a2d9b2629f99..6e8ec0813f594d8de246eac446eef19d08f30975 100644 (file)
@@ -16,10 +16,11 @@ import {
     navigateToItem,
     editSavedQuery,
     changeData,
-    submitData, moveUp, moveDown, setAdvancedDataFromSearchValue
+    submitData, moveUp, moveDown, setAdvancedDataFromSearchValue, SEARCH_BAR_ADVANCED_FORM_NAME
 } from '~/store/search-bar/search-bar-actions';
 import { SearchBarView, SearchBarActionProps, SearchBarDataProps } from '~/views-components/search-bar/search-bar-view';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
+import { Vocabulary } from '~/models/vocabulary';
 
 const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps => {
     return {
@@ -29,10 +30,10 @@ const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps =>
         searchResults: searchBar.searchResults,
         selectedItem: searchBar.selectedItem,
         savedQueries: searchBar.savedQueries,
-        tags: form.searchBarAdvanceFormName,
-        saveQuery: form.searchBarAdvanceFormName &&
-            form.searchBarAdvanceFormName.values &&
-            form.searchBarAdvanceFormName.values.saveQuery
+        tags: form[SEARCH_BAR_ADVANCED_FORM_NAME],
+        saveQuery: form[SEARCH_BAR_ADVANCED_FORM_NAME] &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values!.saveQuery
     };
 };
 
@@ -47,10 +48,10 @@ const mapDispatchToProps = (dispatch: Dispatch): SearchBarActionProps => ({
     deleteSavedQuery: (id: number) => dispatch<any>(deleteSavedQuery(id)),
     openSearchView: () => dispatch<any>(openSearchView()),
     navigateTo: (uuid: string) => dispatch<any>(navigateToItem(uuid)),
-    editSavedQuery: (data: SearchBarAdvanceFormData) => dispatch<any>(editSavedQuery(data)),
+    editSavedQuery: (data: SearchBarAdvancedFormData) => dispatch<any>(editSavedQuery(data)),
     moveUp: () => dispatch<any>(moveUp()),
     moveDown: () => dispatch<any>(moveDown()),
-    setAdvancedDataFromSearchValue: (search: string) => dispatch<any>(setAdvancedDataFromSearchValue(search))
+    setAdvancedDataFromSearchValue: (search: string, vocabulary: Vocabulary) => dispatch<any>(setAdvancedDataFromSearchValue(search, vocabulary))
 });
 
 export const SearchBar = connect(mapStateToProps, mapDispatchToProps)(SearchBarView);
index da5cd0124405c51c1d8476f68877beaeb247ed32..90b4f83d040139138807b3286f699e68b5523bff 100644 (file)
@@ -3,11 +3,10 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { MenuItem, Select, withStyles, StyleRulesCallback } from '@material-ui/core';
+import { MenuItem, Select } from '@material-ui/core';
 import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
 import Edit from '@material-ui/icons/Edit';
 import Computer from '@material-ui/icons/Computer';
-import { WithStyles } from '@material-ui/core/styles';
 import { SelectProps } from '@material-ui/core/Select';
 import { SelectItem } from './select-item';
 import { PermissionLevel } from '../../models/permission';
index c407bc1fc34f6b721e3104a729e30c73e4fef40b..26aee59af4ee792790ca17bb8a79295eb90d7211 100644 (file)
@@ -14,6 +14,8 @@ import { 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';
 import { noop } from 'lodash';
+import { ResourceKind } from "~/models/resource";
+import { IllegalNamingWarning } from "~/components/warning/warning";
 export interface SidePanelTreeProps {
     onItemActivation: (id: string) => void;
     sidePanelProgress?: boolean;
@@ -39,14 +41,20 @@ export const SidePanelTree = connect(undefined, mapDispatchToProps)(
     (props: SidePanelTreeActionProps) =>
         <TreePicker {...props} render={renderSidePanelItem} pickerId={SIDE_PANEL_TREE} />);
 
-const renderSidePanelItem = (item: TreeItem<ProjectResource>) =>
-    <ListItemTextIcon
+const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
+    const name = typeof item.data === 'string' ? item.data : item.data.name;
+    const warn = typeof item.data !== 'string' && item.data.kind === ResourceKind.PROJECT
+        ? <IllegalNamingWarning name={name} />
+        : undefined;
+    return <ListItemTextIcon
         icon={getProjectPickerIcon(item)}
-        name={typeof item.data === 'string' ? item.data : item.data.name}
+        name={name}
+        nameDecorator={warn}
         isActive={item.active}
         hasMargin={true}
         iconSize={1.25}
     />;
+};
 
 const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
     typeof item.data === 'string'
index 62d9dc3532e2efc48605b261e99ddffaa20d7133..6599a3fc795a8ca3b1b03eedf42797b3b0c5fbc0 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { SidePanelTree, SidePanelTreeProps } from '~/views-components/side-panel-tree/side-panel-tree';
-import { compose, Dispatch } from 'redux';
+import { Dispatch } from 'redux';
 import { connect } from 'react-redux';
 import { navigateFromSidePanel } from '~/store/side-panel/side-panel-action';
 import { Grid } from '@material-ui/core';
index 5d799f0b27c8fd040ac23c1f90acc5cfa8648d8e..b92557f9de35b59557318ea6a4ba8b69f5f3c588 100644 (file)
@@ -5,26 +5,26 @@
 import * as React from 'react';
 import {
     StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Chip, Tooltip
+    CardHeader, IconButton, CardContent, Grid, Tooltip
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RootState } from '~/store/store';
-import { MoreOptionsIcon, CollectionIcon, CopyIcon } from '~/components/icon/icon';
+import { MoreOptionsIcon, CollectionIcon } from '~/components/icon/icon';
 import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
 import { CollectionResource } from '~/models/collection';
 import { CollectionPanelFiles } from '~/views-components/collection-panel-files/collection-panel-files';
 import { CollectionTagForm } from './collection-tag-form';
 import { deleteCollectionTag, navigateToProcess } from '~/store/collection-panel/collection-panel-action';
-import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { getResource } from '~/store/resources/resources';
 import { openContextMenu } from '~/store/context-menu/context-menu-actions';
 import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { formatFileSize } from "~/common/formatters";
-import { getResourceData } from "~/store/resources-data/resources-data";
-import { ResourceData } from "~/store/resources-data/resources-data-reducer";
 import { openDetailsPanel } from '~/store/details-panel/details-panel-action';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { PropertyChipComponent } from '~/views-components/resource-properties-form/property-chip';
+import { IllegalNamingWarning } from '~/components/warning/warning';
 
 type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link';
 
@@ -58,7 +58,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 interface CollectionPanelDataProps {
     item: CollectionResource;
-    data: ResourceData;
 }
 
 type CollectionPanelProps = CollectionPanelDataProps & DispatchProp
@@ -67,13 +66,12 @@ type CollectionPanelProps = CollectionPanelDataProps & DispatchProp
 export const CollectionPanel = withStyles(styles)(
     connect((state: RootState, props: RouteComponentProps<{ id: string }>) => {
         const item = getResource(props.match.params.id)(state.resources);
-        const data = getResourceData(props.match.params.id)(state.resourcesData);
-        return { item, data };
+        return { item };
     })(
         class extends React.Component<CollectionPanelProps> {
 
             render() {
-                const { classes, item, data, dispatch } = this.props;
+                const { classes, item, dispatch } = this.props;
                 return item
                     ? <>
                         <Card className={classes.card}>
@@ -92,7 +90,7 @@ export const CollectionPanel = withStyles(styles)(
                                         </IconButton>
                                     </Tooltip>
                                 }
-                                title={item && item.name}
+                                title={item && <span><IllegalNamingWarning name={item.name}/>{item.name}</span>}
                                 titleTypographyProps={this.titleProps}
                                 subheader={item && item.description}
                                 subheaderTypographyProps={this.titleProps} />
@@ -106,9 +104,9 @@ export const CollectionPanel = withStyles(styles)(
                                             label='Portable data hash'
                                             linkToUuid={item && item.portableDataHash} />
                                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                                            label='Number of files' value={data && data.fileCount} />
+                                            label='Number of files' value={item && item.fileCount} />
                                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                                            label='Content size' value={data && formatFileSize(data.fileSize)} />
+                                            label='Content size' value={item && formatFileSize(item.fileSizeTotal)} />
                                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
                                             label='Owner' linkToUuid={item && item.ownerUuid} />
                                         {(item.properties.container_request || item.properties.containerRequest) &&
@@ -129,13 +127,12 @@ export const CollectionPanel = withStyles(styles)(
                                         <CollectionTagForm />
                                     </Grid>
                                     <Grid item xs={12}>
-                                        {
-                                            Object.keys(item.properties).map(k => {
-                                                return <Chip key={k} className={classes.tag}
-                                                    onDelete={this.handleDelete(k)}
-                                                    label={`${k}: ${item.properties[k]}`} />;
-                                            })
-                                        }
+                                        {Object.keys(item.properties).map(k =>
+                                            <PropertyChipComponent
+                                                key={k} className={classes.tag}
+                                                onDelete={this.handleDelete(k)}
+                                                propKey={k} propValue={item.properties[k]} />
+                                        )}
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -162,6 +159,13 @@ export const CollectionPanel = withStyles(styles)(
                 this.props.dispatch<any>(openContextMenu(event, resource));
             }
 
+            onCopy = (message: string) =>
+                this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }))
+
             handleDelete = (key: string) => () => {
                 this.props.dispatch<any>(deleteCollectionTag(key));
             }
index 8d53a21ecaa060fd659d6c92ac7d9e4f83e6b692..42262deb06beb67da2e3caa550300e3e026ef7c5 100644 (file)
@@ -9,7 +9,7 @@ import { Grid, Typography, Button } from '@material-ui/core';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { navigateToLinkAccount } from '~/store/navigation/navigation-action';
-
+import { RootState } from '~/store/store';
 
 type CssRules = 'root' | 'ontop' | 'title';
 
@@ -47,31 +47,32 @@ const mapDispatchToProps = (dispatch: Dispatch): InactivePanelActionProps => ({
     }
 });
 
-type InactivePanelProps =  WithStyles<CssRules> & InactivePanelActionProps;
+export interface InactivePanelStateProps {
+    inactivePageText: string;
+}
+
+type InactivePanelProps = WithStyles<CssRules> & InactivePanelActionProps & InactivePanelStateProps;
 
-export const InactivePanel = connect(null, mapDispatchToProps)(withStyles(styles)((({ classes, startLinking }: InactivePanelProps) =>
-        <Grid container justify="center" alignItems="center" direction="column" spacing={24}
-            className={classes.root}
-            style={{ marginTop: 56, height: "100%" }}>
-            <Grid item>
-                <Typography variant='h6' align="center" className={classes.title}>
-                    Hi! You're logged in, but...
-                </Typography>
-            </Grid>
-            <Grid item>
-                <Typography align="center">
-                    Your account is inactive. An administrator must activate your account before you can get any further.
-                </Typography>
-            </Grid>
-            <Grid item>
-                <Typography align="center">
-                    If you would like to use this login to access another account click "Link Account".
-                </Typography>
-            </Grid>
-            <Grid item>
-                <Button className={classes.ontop} color="primary" variant="contained" onClick={() => startLinking()}>
-                    Link Account
-                </Button>
-            </Grid>
-        </Grid >
-    )));
+export const InactivePanel = connect((state: RootState) => ({
+    inactivePageText: state.auth.config.clusterConfig.Workbench.InactivePageHTML
+}), mapDispatchToProps)(withStyles(styles)((({ classes, startLinking, inactivePageText }: InactivePanelProps) =>
+    <Grid container justify="center" alignItems="center" direction="column" spacing={24}
+        className={classes.root}
+        style={{ marginTop: 56, height: "100%" }}>
+        <Grid item>
+            <Typography>
+                <div dangerouslySetInnerHTML={{ __html: inactivePageText }} style={{ margin: "1em" }} />
+            </Typography>
+        </Grid>
+        <Grid item>
+            <Typography align="center">
+                If you would like to use this login to access another account click "Link Account".
+           </Typography>
+        </Grid>
+        <Grid item>
+            <Button className={classes.ontop} color="primary" variant="contained" onClick={() => startLinking()}>
+                Link Account
+           </Button>
+        </Grid>
+    </Grid >
+)));
index 8c266b616016d8d48318364103c96274106c7722..f2a7ed1c5d5d42dcc3bd002c35ebec075407dff7 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { MoreOptionsIcon } from '~/components/icon/icon';
 import { KeepServiceResource } from '~/models/keep-services';
index 0eb494e67c0b721de9b15f574f5e57ab353c9d2d..98d19acedf24bb9c303151d3436ba00053b500dc 100644 (file)
@@ -19,6 +19,7 @@ import { UserResource } from "~/models/user";
 import { LinkAccountType } from "~/models/link-account";
 import { formatDate } from "~/common/formatters";
 import { LinkAccountPanelStatus, LinkAccountPanelError } from "~/store/link-account-panel/link-account-panel-reducer";
+import { Config } from '~/common/config';
 
 type CssRules = 'root';
 
@@ -32,10 +33,11 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 export interface LinkAccountPanelRootDataProps {
     targetUser?: UserResource;
     userToLink?: UserResource;
-    remoteHosts:  { [key: string]: string };
+    remoteHostsConfig: { [key: string]: Config };
     hasRemoteHosts: boolean;
     localCluster: string;
-    status : LinkAccountPanelStatus;
+    loginCluster: string;
+    status: LinkAccountPanelStatus;
     error: LinkAccountPanelError;
     selectedCluster?: string;
     isProcessing: boolean;
@@ -52,7 +54,7 @@ function displayUser(user: UserResource, showCreatedAt: boolean = false, showClu
     const disp = [];
     disp.push(<span><b>{user.email}</b> ({user.username}, {user.uuid})</span>);
     if (showCluster) {
-        const homeCluster = user.uuid.substr(0,5);
+        const homeCluster = user.uuid.substr(0, 5);
         disp.push(<span> hosted on cluster <b>{homeCluster}</b> and </span>);
     }
     if (showCreatedAt) {
@@ -67,116 +69,128 @@ function isLocalUser(uuid: string, localCluster: string) {
 
 type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles<CssRules>;
 
-export const LinkAccountPanelRoot = withStyles(styles) (
-    ({classes, targetUser, userToLink, status, isProcessing, error, startLinking, cancelLinking, linkAccount,
-      remoteHosts, hasRemoteHosts, selectedCluster, setSelectedCluster, localCluster}: LinkAccountPanelRootProps) => {
+export const LinkAccountPanelRoot = withStyles(styles)(
+    ({ classes, targetUser, userToLink, status, isProcessing, error, startLinking, cancelLinking, linkAccount,
+        remoteHostsConfig, hasRemoteHosts, selectedCluster, setSelectedCluster, localCluster, loginCluster }: LinkAccountPanelRootProps) => {
         return <Card className={classes.root}>
             <CardContent>
-            { isProcessing && <Grid container item direction="column" alignContent="center" spacing={24}>
-                <Grid item>
-                    Loading user info. Please wait.
-                </Grid>
-                <Grid item style={{ alignSelf: 'center' }}>
-                    <CircularProgress/>
-                </Grid>
-            </Grid> }
-            { !isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser && <div>
-                { isLocalUser(targetUser.uuid, localCluster) ? <Grid container spacing={24}>
-                    <Grid container item direction="column" spacing={24}>
-                        <Grid item>
-                            You are currently logged in as {displayUser(targetUser, true)}
-                        </Grid>
-                        <Grid item>
-                            You can link Arvados accounts. After linking, either login will take you to the same account.
-                        </Grid >
+                {isProcessing && <Grid container item direction="column" alignContent="center" spacing={24}>
+                    <Grid item>
+                        Loading user info. Please wait.
+              </Grid>
+                    <Grid item style={{ alignSelf: 'center' }}>
+                        <CircularProgress />
                     </Grid>
-                    <Grid container item direction="row" spacing={24}>
-                        <Grid item>
-                            <Button disabled={!targetUser.isActive} color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_OTHER_LOGIN)}>
-                                Add another login to this account
-                            </Button>
-                        </Grid>
-                        <Grid item>
-                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_ACCOUNT)}>
-                                Use this login to access another account
-                            </Button>
+                </Grid>}
+                {!isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser && <div>
+                    {isLocalUser(targetUser.uuid, localCluster) ? <Grid container spacing={24}>
+                        <Grid container item direction="column" spacing={24}>
+                            <Grid item>
+                                You are currently logged in as {displayUser(targetUser, true)}
+                            </Grid>
+                            <Grid item>
+                                You can link Arvados accounts. After linking, either login will take you to the same account.
+                      </Grid >
                         </Grid>
-                    </Grid>
-                    { hasRemoteHosts && selectedCluster && <Grid container item direction="column" spacing={24}>
-                        <Grid item>
-                            You can also link {displayUser(targetUser, false)} with an account from a remote cluster.
+                        <Grid container item direction="row" spacing={24}>
+                            <Grid item>
+                                <Button disabled={!targetUser.isActive} color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_OTHER_LOGIN)}>
+                                    Add another login to this account
+                          </Button>
+                            </Grid>
+                            <Grid item>
+                                <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_ACCOUNT)}>
+                                    Use this login to access another account
+                          </Button>
+                            </Grid>
                         </Grid>
-                        <Grid item>
-                            Please select the cluster that hosts the account you want to link with:
-                                <Select id="remoteHostsDropdown" native defaultValue={selectedCluster} style={{ marginLeft: "1em" }}
+                        {hasRemoteHosts && selectedCluster && <Grid container item direction="column" spacing={24}>
+                            <Grid item>
+                                You can also link {displayUser(targetUser, false)} with an account from a remote cluster.
+                      </Grid>
+                            <Grid item>
+                                Please select the cluster that hosts the account you want to link with:
+                           <Select id="remoteHostsDropdown" native defaultValue={selectedCluster} style={{ marginLeft: "1em" }}
                                     onChange={(event) => setSelectedCluster(event.target.value)}>
-                                    {Object.keys(remoteHosts).map((k) => k !== localCluster ? <option key={k} value={k}>{k}</option> : null)}
+                                    {Object.keys(remoteHostsConfig).map((k) => k !== localCluster ? <option key={k} value={k}>{k}</option> : null)}
                                 </Select>
                             </Grid>
-                        <Grid item>
-                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT)}>
-                                Link with an account on&nbsp;{hasRemoteHosts ? <label>{selectedCluster} </label> : null}
-                            </Button>
-                        </Grid>
-                    </Grid> }
-                </Grid> :
-                <Grid container spacing={24}>
-                    <Grid container item direction="column" spacing={24}>
-                        <Grid item>
-                            You are currently logged in as {displayUser(targetUser, true, true)}
+                            <Grid item>
+                                <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT)}>
+                                    Link with an account on&nbsp;{hasRemoteHosts ? <label>{selectedCluster} </label> : null}
+                                </Button>
+                            </Grid>
+                        </Grid>}
+                    </Grid> :
+                        <Grid container spacing={24}>
+                            <Grid container item direction="column" spacing={24}>
+                                <Grid item>
+                                    You are currently logged in as {displayUser(targetUser, true, true)}
+                                </Grid>
+                                {targetUser.isActive ?
+                                    (loginCluster === "" ?
+                                        <> <Grid item>
+                                            This a remote account. You can link a local Arvados account to this one.
+                                            After linking, you can access the local account's data by logging into the
+                                           <b>{localCluster}</b> cluster as user <b>{targetUser.email}</b>
+                                            from <b>{targetUser.uuid.substr(0, 5)}</b>.
+                                       </Grid >
+                                        <Grid item>
+                                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_LOCAL_TO_REMOTE)}>
+                                                Link an account from {localCluster} to this account
+                                           </Button>
+                                        </Grid> </>
+                                   : <Grid item>Please visit cluster
+                                      <a href={remoteHostsConfig[loginCluster].workbench2Url + "/link_account"}>{loginCluster}</a>
+                                       to perform account linking.</Grid>
+                                    )
+                                 : <Grid item>
+                                     This an inactive remote account. An administrator must activate your
+                                     account before you can proceed.  After your accounts is activated,
+                                    you can link a local Arvados account hosted by the <b>{localCluster}</b>
+                                     cluster to this one.
+                                </Grid >}
+                            </Grid>
+                        </Grid>}
+                </div>}
+                {!isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser &&
+                    <Grid container spacing={24}>
+                        {status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
+                            <Grid item>
+                                Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}.
+                   </Grid>
+                            {(isLocalUser(targetUser.uuid, localCluster)) && <Grid item>
+                                After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}.
+                   </Grid>}
+                            <Grid item>
+                                Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}.
+                   </Grid>
+                            {!isLocalUser(targetUser.uuid, localCluster) && <Grid item>
+                                You can access <b>{userToLink.email}</b> data by logging into <b>{localCluster}</b> with the <b>{targetUser.email}</b> account.
+                   </Grid>}
+                        </Grid>}
+                        {error === LinkAccountPanelError.NON_ADMIN && <Grid item>
+                            Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}.
+               </Grid>}
+                        {error === LinkAccountPanelError.SAME_USER && <Grid item>
+                            Cannot link {displayUser(targetUser)} to the same account.
+               </Grid>}
+                        {error === LinkAccountPanelError.INACTIVE && <Grid item>
+                            Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}.
+               </Grid>}
+                        <Grid container item direction="row" spacing={24}>
+                            <Grid item>
+                                <Button variant="contained" onClick={() => cancelLinking()}>
+                                    Cancel
+                       </Button>
+                            </Grid>
+                            <Grid item>
+                                <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
+                                    Link accounts
+                       </Button>
+                            </Grid>
                         </Grid>
-                        {targetUser.isActive ? <> <Grid item>
-                            This a remote account. You can link a local Arvados account to this one. After linking, you can access the local account's data by logging into the <b>{localCluster}</b> cluster as user <b>{targetUser.email}</b> from <b>{targetUser.uuid.substr(0,5)}</b>.
-                        </Grid >
-                        <Grid item>
-                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_LOCAL_TO_REMOTE)}>
-                                Link an account from {localCluster} to this account
-                            </Button>
-                        </Grid> </>
-                        : <Grid item>
-                          This an inactive remote account. An administrator must activate your account before you can proceed. After your accounts is activated, you can link a local Arvados account hosted by the <b>{localCluster}</b> cluster to this one.
-                        </Grid >}
-                    </Grid>
-                </Grid>}
-            </div> }
-            { !isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser &&
-            <Grid container spacing={24}>
-                { status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
-                    <Grid item>
-                        Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}.
-                    </Grid>
-                    { (isLocalUser(targetUser.uuid, localCluster)) && <Grid item>
-                        After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}.
-                    </Grid> }
-                    <Grid item>
-                        Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}.
-                    </Grid>
-                    { !isLocalUser(targetUser.uuid, localCluster) && <Grid item>
-                        You can access <b>{userToLink.email}</b> data by logging into <b>{localCluster}</b> with the <b>{targetUser.email}</b> account.
-                    </Grid> }
-                </Grid> }
-                { error === LinkAccountPanelError.NON_ADMIN && <Grid item>
-                    Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}.
-                </Grid> }
-                { error === LinkAccountPanelError.SAME_USER && <Grid item>
-                    Cannot link {displayUser(targetUser)} to the same account.
-                </Grid> }
-                { error === LinkAccountPanelError.INACTIVE && <Grid item>
-                    Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}.
-                </Grid> }
-                <Grid container item direction="row" spacing={24}>
-                    <Grid item>
-                        <Button variant="contained" onClick={() => cancelLinking()}>
-                            Cancel
-                        </Button>
-                    </Grid>
-                    <Grid item>
-                        <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
-                            Link accounts
-                        </Button>
-                    </Grid>
-                </Grid>
-            </Grid> }
-        </CardContent>
-    </Card>;
-});
\ No newline at end of file
+                    </Grid>}
+            </CardContent>
+        </Card>;
+    });
index c3ad51cf61bfa94251f2641a6331afcc09a48e5d..78b7efd21afc892d4609d0b1959ec986fec07ca5 100644 (file)
@@ -5,7 +5,7 @@
 import { RootState } from '~/store/store';
 import { Dispatch } from 'redux';
 import { connect } from 'react-redux';
-import { startLinking, cancelLinking, linkAccount, linkAccountPanelActions } from '~/store/link-account-panel/link-account-panel-actions';
+import { startLinking, linkAccount, linkAccountPanelActions, cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
 import { LinkAccountType } from '~/models/link-account';
 import {
     LinkAccountPanelRoot,
@@ -15,10 +15,11 @@ import {
 
 const mapStateToProps = (state: RootState): LinkAccountPanelRootDataProps => {
     return {
-        remoteHosts: state.auth.remoteHosts,
-        hasRemoteHosts: Object.keys(state.auth.remoteHosts).length > 1,
+        remoteHostsConfig: state.auth.remoteHostsConfig,
+        hasRemoteHosts: Object.keys(state.auth.remoteHosts).length > 1 && state.auth.loginCluster === "",
         selectedCluster: state.linkAccountPanel.selectedCluster,
         localCluster: state.auth.localCluster,
+        loginCluster: state.auth.loginCluster,
         targetUser: state.linkAccountPanel.targetUser,
         userToLink: state.linkAccountPanel.userToLink,
         status: state.linkAccountPanel.status,
@@ -31,7 +32,7 @@ const mapDispatchToProps = (dispatch: Dispatch): LinkAccountPanelRootActionProps
     startLinking: (type: LinkAccountType) => dispatch<any>(startLinking(type)),
     cancelLinking: () => dispatch<any>(cancelLinking(true)),
     linkAccount: () => dispatch<any>(linkAccount()),
-    setSelectedCluster: (selectedCluster: string) => dispatch<any>(linkAccountPanelActions.SET_SELECTED_CLUSTER({selectedCluster}))
+    setSelectedCluster: (selectedCluster: string) => dispatch<any>(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }))
 });
 
 export const LinkAccountPanel = connect(mapStateToProps, mapDispatchToProps)(LinkAccountPanelRoot);
index 41a17bf9682c1b7fb86d5436097ac24673b19693..6fe3eee2aeb6a5425615ae9dec713ec6d4b57bb7 100644 (file)
@@ -4,12 +4,11 @@
 
 import * as React from 'react';
 import { connect, DispatchProp } from 'react-redux';
-import { Grid, Typography, Button, Select, FormControl } from '@material-ui/core';
+import { Grid, Typography, Button, Select } from '@material-ui/core';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { login, authActions } from '~/store/auth/auth-action';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RootState } from '~/store/store';
-import * as classNames from 'classnames';
 
 type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
 
@@ -24,7 +23,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             left: 0,
             bottom: 0,
             right: 0,
-            background: 'url("arvados-logo-big.png") no-repeat center center',
             opacity: 0.2,
         }
     },
@@ -52,37 +50,28 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
     remoteHosts: { [key: string]: string },
     homeCluster: string,
-    uuidPrefix: string
+    uuidPrefix: string,
+    loginCluster: string,
+    welcomePage: string
 };
 
 export const LoginPanel = withStyles(styles)(
     connect((state: RootState) => ({
         remoteHosts: state.auth.remoteHosts,
         homeCluster: state.auth.homeCluster,
-        uuidPrefix: state.auth.localCluster
-    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix }: LoginPanelProps) =>
+        uuidPrefix: state.auth.localCluster,
+        loginCluster: state.auth.loginCluster,
+        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML
+    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix, loginCluster, welcomePage }: LoginPanelProps) =>
         <Grid container justify="center" alignItems="center"
             className={classes.root}
             style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
             <Grid item className={classes.container}>
-                <Typography variant='h6' align="center" className={classes.title}>
-                    Welcome to the Arvados Workbench
-               </Typography>
-                <Typography className={classes.content}>
-                    The "Log in" button below will show you a Google sign-in page.
-                    After you assure Google that you want to log in here with your Google account, you will be redirected back here to Arvados Workbench.
-               </Typography>
-                <Typography className={classes.content}>
-                    If you have never used Arvados Workbench before, logging in for the first time will automatically create a new account.
-               </Typography>
-                <Typography variant='body1' className={classNames(classes.content, classes.content__bolder)}>
-                    IMPORTANT: Please keep in mind to store exploratory data only but not any information used for clinical decision making.
-               </Typography>
-                <Typography className={classes.content}>
-                    Arvados Workbench uses your name and email address only for identification, and does not retrieve any other personal information from Google.
-               </Typography>
+                <Typography>
+                    <div dangerouslySetInnerHTML={{ __html: welcomePage }} style={{ margin: "1em" }} />
+                </Typography>
+                {Object.keys(remoteHosts).length > 1 && loginCluster === "" &&
 
-                {Object.keys(remoteHosts).length > 1 &&
                     <Typography component="div" align="right">
                         <label>Please select the cluster that hosts your user account:</label>
                         <Select native value={homeCluster} style={{ margin: "1em" }}
@@ -93,10 +82,10 @@ export const LoginPanel = withStyles(styles)(
 
                 <Typography component="div" align="right">
                     <Button variant="contained" color="primary" style={{ margin: "1em" }} className={classes.button}
-                        onClick={() => dispatch(login(uuidPrefix, homeCluster, remoteHosts))}>
-                        Log in to {uuidPrefix}
-                        {uuidPrefix !== homeCluster &&
-                            <span>&nbsp;with user from {homeCluster}</span>}
+                        onClick={() => dispatch(login(uuidPrefix, homeCluster, loginCluster, remoteHosts))}>
+                        Log in
+                       {uuidPrefix !== homeCluster && loginCluster !== homeCluster &&
+                            <span>&nbsp;to {uuidPrefix} with user from {homeCluster}</span>}
                     </Button>
                 </Typography>
             </Grid>
index 43bc7fbc158ba6c891ff3d87b60dd0f8d5deca74..e7daaf00932f0a0c8080946b22f340e2114870f9 100644 (file)
@@ -11,7 +11,6 @@ import { LoginPanel } from '~/views/login-panel/login-panel';
 import { InactivePanel } from '~/views/inactive-panel/inactive-panel';
 import { WorkbenchLoadingScreen } from '~/views/workbench/workbench-loading-screen';
 import { MainAppBar } from '~/views-components/main-app-bar/main-app-bar';
-import { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel';
 
 type CssRules = 'root';
 
@@ -31,23 +30,26 @@ export interface MainPanelRootDataProps {
     uuidPrefix: string;
     isNotLinking: boolean;
     isLinkingPath: boolean;
+    siteBanner: string;
 }
 
 type MainPanelRootProps = MainPanelRootDataProps & WithStyles<CssRules>;
 
 export const MainPanelRoot = withStyles(styles)(
-    ({ classes, loading, working, user, buildInfo, uuidPrefix, isNotLinking, isLinkingPath }: MainPanelRootProps) =>
+    ({ classes, loading, working, user, buildInfo, uuidPrefix,
+        isNotLinking, isLinkingPath, siteBanner }: MainPanelRootProps) =>
         loading
             ? <WorkbenchLoadingScreen />
             : <>
-               isNotLinking && <MainAppBar
+                {isNotLinking && <MainAppBar
                     user={user}
                     buildInfo={buildInfo}
-                    uuidPrefix={uuidPrefix}>
+                    uuidPrefix={uuidPrefix}
+                    siteBanner={siteBanner}>
                     {working ? <LinearProgress color="secondary" /> : null}
-               </MainAppBar> }
+                </MainAppBar>}
                 <Grid container direction="column" className={classes.root}>
-                    { user ? (user.isActive || (!user.isActive && isLinkingPath) ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} /> : <InactivePanel />) : <LoginPanel /> }
+                    {user ? (user.isActive || (!user.isActive && isLinkingPath) ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} /> : <InactivePanel />) : <LoginPanel />}
                 </Grid>
             </>
 );
index 5bf03da3986afbe73c8999c43cd119f438652785..5828c6db9054e8fc980a7029b174f7f6816c5893 100644 (file)
@@ -18,7 +18,8 @@ const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
         buildInfo: state.appInfo.buildInfo,
         uuidPrefix: state.auth.localCluster,
         isNotLinking: state.linkAccountPanel.status === LinkAccountPanelStatus.NONE || state.linkAccountPanel.status === LinkAccountPanelStatus.INITIAL,
-        isLinkingPath:  state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false
+        isLinkingPath: state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false,
+        siteBanner: state.auth.config.clusterConfig.Workbench.SiteName
     };
 };
 
index a7d83daef57f82b9cbbd8bf5f70ca1088ae78f9e..7b238832b09139152d500f00a2671a6d602f85b1 100644 (file)
@@ -11,7 +11,7 @@ import {
 } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { ERROR_MESSAGE } from '~/validators/require';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, Grid, WithStyles, Typography } from '@material-ui/core';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core';
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
 import { connect, DispatchProp } from 'react-redux';
index c134548c734dee8270d4e36d4ba9793a5e065d98..88f8a00c1c496a534cc91c38fcd4e2fb83637b36 100644 (file)
@@ -11,7 +11,7 @@ import {
 } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { ERROR_MESSAGE } from '~/validators/require';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, Grid, WithStyles, Typography } from '@material-ui/core';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core';
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
 import { connect, DispatchProp } from 'react-redux';
index 30ff494c1ebbae138811432817fc298244ab5a73..0400b850a2b460ca2fe2d7d80ae98da6647c9fda 100644 (file)
@@ -9,7 +9,6 @@ import { Grid } from '@material-ui/core';
 import { TextField } from '~/components/text-field/text-field';
 import { ExpandIcon } from '~/components/icon/icon';
 import * as IntInput from './inputs/int-input';
-import { require } from '~/validators/require';
 import { min } from '~/validators/min';
 import { optional } from '~/validators/optional';
 import { SwitchField } from '~/components/switch-field/switch-field';
@@ -20,7 +19,7 @@ export const OUTPUT_FIELD = 'output';
 export const RUNTIME_FIELD = 'runtime';
 export const RAM_FIELD = 'ram';
 export const VCPUS_FIELD = 'vcpus';
-export const KEEP_CACHE_RAM_FIELD = 'keepCacheRam';
+export const KEEP_CACHE_RAM_FIELD = 'keep_cache_ram';
 export const API_FIELD = 'api';
 
 export interface RunProcessAdvancedFormData {
@@ -93,7 +92,7 @@ export const RunProcessAdvancedForm =
                                 parse={IntInput.parse}
                                 format={IntInput.format}
                                 type='number'
-                                validate={keepCacheRamValdation} />
+                                validate={keepCacheRamValidation} />
                         </Grid>
                         <Grid item xs={12} md={6}>
                             <Field
@@ -112,5 +111,5 @@ export const RunProcessAdvancedForm =
 
 const ramValidation = [min(0)];
 const vcpusValidation = [min(1)];
-const keepCacheRamValdation = [optional(min(0))];
+const keepCacheRamValidation = [optional(min(0))];
 const runtimeValidation = [optional(min(1))];
index 9b3379a385264d2edb58f5ad6da91e109577ae31..45b971179384671f6c3175e2544c19c60cec1fd9 100644 (file)
@@ -7,7 +7,7 @@ import { reduxForm, InjectedFormProps } from 'redux-form';
 import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter, DirectoryArrayCommandInputParameter, FloatArrayCommandInputParameter, IntArrayCommandInputParameter } from '~/models/workflow';
 import { IntInput } from '~/views/run-process-panel/inputs/int-input';
 import { StringInput } from '~/views/run-process-panel/inputs/string-input';
-import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, File, Directory, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter } from '../../models/workflow';
+import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter } from '../../models/workflow';
 import { FloatInput } from '~/views/run-process-panel/inputs/float-input';
 import { BooleanInput } from './inputs/boolean-input';
 import { FileInput } from './inputs/file-input';
index 8e855ab395a95666e11d1f708315465bf5aff098..a2ed9a21f1474464b9e867423f2afe91e5bfbeca 100644 (file)
@@ -12,7 +12,7 @@ import { RootState } from '~/store/store';
 import { isValid } from 'redux-form';
 import { RUN_PROCESS_INPUTS_FORM } from './run-process-inputs-form';
 import { RunProcessAdvancedForm, RUN_PROCESS_ADVANCED_FORM } from './run-process-advanced-form';
-import { createSelector, createStructuredSelector } from 'reselect';
+import { createStructuredSelector } from 'reselect';
 import { WorkflowPresetSelect } from '~/views/run-process-panel/workflow-preset-select';
 import { selectPreset } from '~/store/run-process-panel/run-process-panel-actions';
 
index b82b174520ced7cf922d4fec423f3c0c0372051d..8bc5419b854fce5091401316b6d68795ce235c7f 100644 (file)
@@ -21,6 +21,10 @@ import {
 import { createTree } from '~/models/tree';
 import { getInitialResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
 import { SearchResultsPanelProps } from "./search-results-panel";
+import { Routes } from '~/routes/routes';
+import { Link } from 'react-router-dom';
+import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
 
 export enum SearchResultsPanelColumnNames {
     CLUSTER = "Cluster",
@@ -33,6 +37,15 @@ export enum SearchResultsPanelColumnNames {
     LAST_MODIFIED = "Last modified"
 }
 
+export type CssRules = 'siteManagerLink';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    siteManagerLink: {
+        marginRight: theme.spacing.unit * 2,
+        float: 'right'
+    }
+});
+
 export interface WorkflowPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
@@ -98,19 +111,28 @@ export const searchResultsPanelColumns: DataColumns<string> = [
     }
 ];
 
-export const SearchResultsPanelView = (props: SearchResultsPanelProps) => {
-    const homeCluster = props.user.uuid.substr(0, 5);
-    return <DataExplorer
-        id={SEARCH_RESULTS_PANEL_ID}
-        onRowClick={props.onItemClick}
-        onRowDoubleClick={props.onItemDoubleClick}
-        onContextMenu={props.onContextMenu}
-        contextMenuColumn={false}
-        hideSearchInput
-        title={
-            props.localCluster === homeCluster ?
-                <div>Searching clusters: {props.sessions.filter((ss) => ss.loggedIn).map((ss) => <span key={ss.clusterId}> {ss.clusterId}</span>)}</div> :
-                <div>Searching local cluster {props.localCluster} only.  To search multiple clusters, <a href={props.remoteHostsConfig[homeCluster] && props.remoteHostsConfig[homeCluster].workbench2Url}> start from your home Workbench.</a></div>
-        }
-    />;
-};
+export const SearchResultsPanelView = withStyles(styles, { withTheme: true })(
+    (props: SearchResultsPanelProps & WithStyles<CssRules, true>) => {
+        const homeCluster = props.user.uuid.substr(0, 5);
+        const loggedIn = props.sessions.filter((ss) => ss.loggedIn);
+        return <DataExplorer
+            id={SEARCH_RESULTS_PANEL_ID}
+            onRowClick={props.onItemClick}
+            onRowDoubleClick={props.onItemDoubleClick}
+            onContextMenu={props.onContextMenu}
+            contextMenuColumn={false}
+            hideSearchInput
+            title={
+                <div>
+                    {loggedIn.length === 1 ?
+                        <span>Searching local cluster <ResourceCluster uuid={props.localCluster} /></span>
+                        : <span>Searching clusters: {loggedIn.map((ss) => <span key={ss.clusterId}>
+                            <a href={props.remoteHostsConfig[ss.clusterId] && props.remoteHostsConfig[ss.clusterId].workbench2Url} style={{ textDecoration: 'none' }}> <ResourceCluster uuid={ss.clusterId} /></a>
+                        </span>)}</span>}
+                    {loggedIn.length === 1 && props.localCluster !== homeCluster ?
+                        <span>To search multiple clusters, <a href={props.remoteHostsConfig[homeCluster] && props.remoteHostsConfig[homeCluster].workbench2Url}> start from your home Workbench.</a></span>
+                        : <span style={{ marginLeft: "2em" }}>Use <Link to={Routes.SITE_MANAGER} >Site Manager</Link> to manage which clusters will be searched.</span>}
+                </div >
+            }
+        />;
+    });
index 7de1abd3c501937ae9e7dcbf8aac97fb4b26d3ce..0cc52e5efc9a0d7186e8e97853efe5b3015cb620 100644 (file)
@@ -10,13 +10,13 @@ import { navigateTo } from '~/store/navigation/navigation-action';
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { SearchResultsPanelView } from '~/views/search-results-panel/search-results-panel-view';
 import { RootState } from '~/store/store';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import { User } from "~/models/user";
 import { Config } from '~/common/config';
 import { Session } from "~/models/session";
 
 export interface SearchResultsPanelDataProps {
-    data: SearchBarAdvanceFormData;
+    data: SearchBarAdvancedFormData;
     user: User;
     sessions: Session[];
     remoteHostsConfig: { [key: string]: Config };
index 684e35b4d31680a630c846d506cb05fb6d38241f..223e373c58187e34bd3b3f5ea8dfce7ae1c9ec88 100644 (file)
@@ -8,6 +8,7 @@ import {
     CardContent,
     CircularProgress,
     Grid,
+    IconButton,
     StyleRulesCallback,
     Table,
     TableBody,
@@ -26,6 +27,9 @@ import { Field, FormErrors, InjectedFormProps, reduxForm, reset, stopSubmit } fr
 import { TextField } from "~/components/text-field/text-field";
 import { addSession } from "~/store/auth/auth-action-session";
 import { SITE_MANAGER_REMOTE_HOST_VALIDATION } from "~/validators/validators";
+import { Config } from '~/common/config';
+import { ResourceCluster } from '~/views-components/data-explorer/renderers';
+import { TrashIcon } from "~/components/icon/icon";
 
 type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' |
     'remoteSiteInfo' | 'buttonAdd' | 'buttonLoggedIn' | 'buttonLoggedOut' |
@@ -33,8 +37,8 @@ type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' |
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
-       width: '100%',
-       overflow: 'auto'
+        width: '100%',
+        overflow: 'auto'
     },
     link: {
         color: theme.palette.primary.main,
@@ -84,10 +88,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 export interface SiteManagerPanelRootActionProps {
     toggleSession: (session: Session) => void;
+    removeSession: (session: Session) => void;
 }
 
 export interface SiteManagerPanelRootDataProps {
     sessions: Session[];
+    remoteHostsConfig: { [key: string]: Config };
+    localClusterConfig: Config;
 }
 
 type SiteManagerPanelRootProps = SiteManagerPanelRootDataProps & SiteManagerPanelRootActionProps & WithStyles<CssRules> & InjectedFormProps;
@@ -95,7 +102,7 @@ const SITE_MANAGER_FORM_NAME = 'siteManagerForm';
 
 const submitSession = (remoteHost: string) =>
     (dispatch: Dispatch) => {
-        dispatch<any>(addSession(remoteHost)).then(() => {
+        dispatch<any>(addSession(remoteHost, undefined, true)).then(() => {
             dispatch(reset(SITE_MANAGER_FORM_NAME));
         }).catch((e: any) => {
             const errors = {
@@ -106,7 +113,7 @@ const submitSession = (remoteHost: string) =>
     };
 
 export const SiteManagerPanelRoot = compose(
-    reduxForm<{remoteHost: string}>({
+    reduxForm<{ remoteHost: string }>({
         form: SITE_MANAGER_FORM_NAME,
         touchOnBlur: false,
         onSubmit: (data, dispatch) => {
@@ -114,14 +121,14 @@ export const SiteManagerPanelRoot = compose(
         }
     }),
     withStyles(styles))
-    (({ classes, sessions, handleSubmit, toggleSession }: SiteManagerPanelRootProps) =>
+    (({ classes, sessions, handleSubmit, toggleSession, removeSession, localClusterConfig, remoteHostsConfig }: SiteManagerPanelRootProps) =>
         <Card className={classes.root}>
             <CardContent>
                 <Grid container direction="row">
                     <Grid item xs={12}>
-                        <Typography  paragraph={true} >
+                        <Typography paragraph={true} >
                             You can log in to multiple Arvados sites here, then use the multi-site search page to search collections and projects on all sites at once.
-                        </Typography>
+                   </Typography>
                     </Grid>
                 </Grid>
                 <Grid item xs={12}>
@@ -129,18 +136,23 @@ export const SiteManagerPanelRoot = compose(
                         <TableHead>
                             <TableRow className={classes.tableRow}>
                                 <TableCell>Cluster ID</TableCell>
-                                <TableCell>Username</TableCell>
+                                <TableCell>Host</TableCell>
                                 <TableCell>Email</TableCell>
+                                <TableCell>UUID</TableCell>
                                 <TableCell>Status</TableCell>
+                                <TableCell>Actions</TableCell>
                             </TableRow>
                         </TableHead>
                         <TableBody>
                             {sessions.map((session, index) => {
                                 const validating = session.status === SessionStatus.BEING_VALIDATED;
                                 return <TableRow key={index} className={classes.tableRow}>
-                                    <TableCell>{session.clusterId}</TableCell>
-                                    <TableCell>{validating ? <CircularProgress size={20}/> : session.username}</TableCell>
-                                    <TableCell>{validating ? <CircularProgress size={20}/> : session.email}</TableCell>
+                                    <TableCell>{remoteHostsConfig[session.clusterId] ?
+                                        <a href={remoteHostsConfig[session.clusterId].workbench2Url} style={{ textDecoration: 'none' }}> <ResourceCluster uuid={session.clusterId} /></a>
+                                        : session.clusterId}</TableCell>
+                                    <TableCell>{session.remoteHost}</TableCell>
+                                    <TableCell>{validating ? <CircularProgress size={20} /> : session.email}</TableCell>
+                                    <TableCell>{validating ? <CircularProgress size={20} /> : session.uuid}</TableCell>
                                     <TableCell className={classes.statusCell}>
                                         <Button fullWidth
                                             disabled={validating || session.status === SessionStatus.INVALIDATED || session.active}
@@ -149,6 +161,13 @@ export const SiteManagerPanelRoot = compose(
                                             {validating ? "Validating" : (session.loggedIn ? "Logged in" : "Logged out")}
                                         </Button>
                                     </TableCell>
+                                    <TableCell>
+                                        {session.clusterId !== localClusterConfig.uuidPrefix &&
+                                            !localClusterConfig.clusterConfig.RemoteClusters[session.clusterId] &&
+                                            <IconButton onClick={() => removeSession(session)}>
+                                                <TrashIcon />
+                                            </IconButton>}
+                                    </TableCell>
                                 </TableRow>;
                             })}
                         </TableBody>
@@ -157,9 +176,9 @@ export const SiteManagerPanelRoot = compose(
                 <form onSubmit={handleSubmit}>
                     <Grid container direction="row">
                         <Grid item xs={12}>
-                            <Typography  paragraph={true} className={classes.remoteSiteInfo}>
+                            <Typography paragraph={true} className={classes.remoteSiteInfo}>
                                 To add a remote Arvados site, paste the remote site's host here (see "ARVADOS_API_HOST" on the "current token" page).
-                            </Typography>
+                        </Typography>
                         </Grid>
                         <Grid item xs={8}>
                             <Field
@@ -169,7 +188,7 @@ export const SiteManagerPanelRoot = compose(
                                 placeholder="zzzz.arvadosapi.com"
                                 margin="normal"
                                 label="New cluster"
-                                autoFocus/>
+                                autoFocus />
                         </Grid>
                         <Grid item xs={3}>
                             <Button type="submit" variant="contained" color="primary"
index 4532e856deffa2d7075f369eb15a5a1d6a849fe2..da7ae4288b280acd1cdec275aa3741658661ab03 100644 (file)
@@ -10,18 +10,23 @@ import {
     SiteManagerPanelRootDataProps
 } from "~/views/site-manager-panel/site-manager-panel-root";
 import { Session } from "~/models/session";
-import { toggleSession } from "~/store/auth/auth-action-session";
+import { toggleSession, removeSession } from "~/store/auth/auth-action-session";
 
 const mapStateToProps = (state: RootState): SiteManagerPanelRootDataProps => {
     return {
-        sessions: state.auth.sessions
+        sessions: state.auth.sessions,
+        remoteHostsConfig: state.auth.remoteHostsConfig,
+        localClusterConfig: state.auth.remoteHostsConfig[state.auth.localCluster]
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): SiteManagerPanelRootActionProps => ({
     toggleSession: (session: Session) => {
         dispatch<any>(toggleSession(session));
-    }
+    },
+    removeSession: (session: Session) => {
+        dispatch<any>(removeSession(session.clusterId));
+    },
 });
 
 export const SiteManagerPanel = connect(mapStateToProps, mapDispatchToProps)(SiteManagerPanelRoot);
index 18df96182d636c172370b76baef9a93d973a90d9..dec5af513f4d161a1e422aa591fcd48e730adb6c 100644 (file)
@@ -33,7 +33,6 @@ import { Dispatch } from "redux";
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
 import { createTree } from '~/models/tree';
 import {
-    getInitialResourceTypeFilters,
     getTrashPanelTypeFilters
 } from '~/store/resource-type-filters/resource-type-filters';
 
index 6c2fdafe337cec8b2ed8c2f046bce252e0905603..debc20825281cee5dbd900696bbbbcc66c247439 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { WithStyles, withStyles, Typography, Tabs, Tab, Paper, Button, Grid } from '@material-ui/core';
+import { WithStyles, withStyles, Tabs, Tab, Paper, Button, Grid } from '@material-ui/core';
 import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
 import { connect, DispatchProp } from 'react-redux';
 import { DataColumns } from '~/components/data-table/data-table';
index c64778a3c805f203ba97a9bde63a720cab690540..7c8b87c7f022f42db31404309bd3ad7657583272 100644 (file)
@@ -5,7 +5,6 @@
 import * as React from 'react';
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
-import { AuthState } from '~/store/auth/auth-reducer';
 import { User } from "~/models/user";
 import { getSaltedToken } from '~/store/auth/auth-action-session';
 import { Config } from '~/common/config';
@@ -31,7 +30,6 @@ export const FedLogin = connect(mapStateToProps)(
             if (!apiToken || !user || !user.uuid.startsWith(localCluster)) {
                 return <></>;
             }
-            const [, tokenUuid, token] = apiToken.split("/");
             return <div id={"fedtoken-iframe-div"}>
                 {Object.keys(remoteHostsConfig)
                     .map((k) => {
@@ -42,7 +40,9 @@ export const FedLogin = connect(mapStateToProps)(
                             console.log(`Cluster ${k} does not define workbench2Url.  Federated login / cross-site linking to ${k} is unavailable.  Tell the admin of ${k} to set Services->Workbench2->ExternalURL in config.yml.`);
                             return;
                         }
-                        return <iframe key={k} src={`${remoteHostsConfig[k].workbench2Url}/fedtoken?api_token=${getSaltedToken(k, tokenUuid, token)}`} style={{
+                        const fedtoken = (remoteHostsConfig[k].loginCluster === localCluster)
+                            ? apiToken : getSaltedToken(k, apiToken);
+                        return <iframe key={k} src={`${remoteHostsConfig[k].workbench2Url}/fedtoken?api_token=${fedtoken}`} style={{
                             height: 0,
                             width: 0,
                             visibility: "hidden"
index d015d4ec363255c5982ce3a8bb12af2393d1b2ec..8ab042d289650fb3ee3637f26d952a891229c3db 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { connect } from 'react-redux';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { Route, Switch } from "react-router";
 import { ProjectPanel } from "~/views/project-panel/project-panel";
index d8fa100e73d9d8f0061279c11407e6f2ead0a6fa..45f4423b219c8286a388275140c13095dcffaa64 100644 (file)
@@ -50,18 +50,19 @@ export enum ResourceStatus {
     SHARED = "Shared"
 }
 
-const resourceStatus = (type: string) => {
-    switch (type) {
-        case ResourceStatus.PUBLIC:
-            return "Public";
-        case ResourceStatus.PRIVATE:
-            return "Private";
-        case ResourceStatus.SHARED:
-            return "Shared";
-        default:
-            return "Unknown";
-    }
-};
+// TODO: restore filters
+// const resourceStatus = (type: string) => {
+//     switch (type) {
+//         case ResourceStatus.PUBLIC:
+//             return "Public";
+//         case ResourceStatus.PRIVATE:
+//             return "Private";
+//         case ResourceStatus.SHARED:
+//             return "Shared";
+//         default:
+//             return "Unknown";
+//     }
+// };
 
 export const workflowPanelColumns: DataColumns<string> = [
     {
index e3f1e192023e8acda5aa84db991010b48a83d3e4..d10414616a359982c1e9d77d010e8327062fe620 100644 (file)
@@ -15,9 +15,13 @@ import { addProcessLogsPanelItem } from '../store/process-logs-panel/process-log
 import { FilterBuilder } from "~/services/api/filter-builder";
 
 export const initWebSocket = (config: Config, authService: AuthService, store: RootStore) => {
-    const webSocketService = new WebSocketService(config.websocketUrl, authService);
-    webSocketService.setMessageListener(messageListener(store));
-    webSocketService.connect();
+    if (config.websocketUrl) {
+        const webSocketService = new WebSocketService(config.websocketUrl, authService);
+        webSocketService.setMessageListener(messageListener(store));
+        webSocketService.connect();
+    } else {
+        console.warn("WARNING: Websocket ExternalURL is not set on the cluster config.");
+    }
 };
 
 const messageListener = (store: RootStore) => (message: ResourceEventMessage) => {
index 003e9021cd6d3c6dd0d707165ec8eaf5746e3aaa..2a7c3c051c4b97168713136f0e825243ffc22ff6 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
+  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier", "tslint-etc"],
   "rules": {
     "ordered-imports": false,
     "member-ordering": false,
     "interface-over-type-literal": false,
     "no-empty": false,
     "no-bitwise": false,
-    "ban-types": false
+    "ban-types": false,
+    "no-unused-declaration": true
   },
   "linterOptions": {
     "exclude": [
       "config/**/*.js",
       "node_modules/**/*.ts",
       "src/lib/**",
-      "coverage/lcov-report/*.js"
+      "src/**/*.test.ts",
+      "coverage/lcov-report/*.js",
+      "src/common/custom-theme.ts"
     ]
   }
 }
index 979441e1eb56c6ff8d3224964e3bd3f0d314f0b5..d33d165d762b2565222696933b8d5282345012d2 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     "@babel/runtime" "7.0.0"
     recompose "^0.29.0"
 
+"@phenomnomnominal/tsquery@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@phenomnomnominal/tsquery/-/tsquery-3.0.0.tgz#6f2f4dbf6304ff52b12cc7a5b979f20c3794a22a"
+  integrity sha512-SW8lKitBHWJ9fAYkJ9kJivuctwNYCh3BUxLdH0+XiR1GPBiu+7qiZzh8p8jqlj1LgVC1TbvfNFroaEsmYlL8Iw==
+  dependencies:
+    esquery "^1.0.1"
+
+"@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.6.0.tgz#ec7670432ae9c8eb710400d112c201a362d83393"
+  integrity sha512-w4/WHG7C4WWFyE5geCieFJF6MZkbW4VAriol5KlmQXpAQdxvV0p26sqNZOW6Qyw6Y0l9K4g+cHvvczR2sEEpqg==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/formatio@^3.2.1":
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.2.2.tgz#771c60dfa75ea7f2d68e3b94c7e888a78781372c"
+  integrity sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==
+  dependencies:
+    "@sinonjs/commons" "^1"
+    "@sinonjs/samsam" "^3.1.0"
+
+"@sinonjs/samsam@^3.1.0", "@sinonjs/samsam@^3.3.1":
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.3.tgz#46682efd9967b259b81136b9f120fd54585feb4a"
+  integrity sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==
+  dependencies:
+    "@sinonjs/commons" "^1.3.0"
+    array-from "^2.1.1"
+    lodash "^4.17.15"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
+  integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
+
 "@types/cheerio@*":
   version "0.22.9"
   resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.9.tgz#b5990152604c2ada749b7f88cab3476f21f39d7b"
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@types/shell-quote/-/shell-quote-1.6.0.tgz#537b2949a2ebdcb0d353e448fee45b081021963f"
 
+"@types/sinon@7.5":
+  version "7.5.1"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.5.1.tgz#d27b81af0d1cfe1f9b24eebe7a24f74ae40f5b7c"
+  integrity sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ==
+
 "@types/uuid@3.4.4":
   version "3.4.4"
   resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"
@@ -467,6 +508,11 @@ array-flatten@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296"
 
+array-from@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195"
+  integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=
+
 array-includes@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d"
@@ -620,12 +666,13 @@ axios-mock-adapter@1.15.0:
   dependencies:
     deep-equal "^1.0.1"
 
-axios@0.18.0:
-  version "0.18.0"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102"
+axios@0.18.1:
+  version "0.18.1"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3"
+  integrity sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==
   dependencies:
-    follow-redirects "^1.3.0"
-    is-buffer "^1.1.5"
+    follow-redirects "1.5.10"
+    is-buffer "^2.0.2"
 
 babel-code-frame@6.26.0, babel-code-frame@^6.11.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0:
   version "6.26.0"
@@ -1642,6 +1689,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1:
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
+chalk@^2.1.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
 change-emitter@^0.1.2:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
@@ -2399,7 +2455,7 @@ detect-port-alt@1.1.6:
     address "^1.0.1"
     debug "^2.6.0"
 
-diff@^3.2.0:
+diff@^3.2.0, diff@^3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
 
@@ -2834,12 +2890,24 @@ esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
 
+esquery@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
+  integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
+  dependencies:
+    estraverse "^4.0.0"
+
 esrecurse@^4.1.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
   dependencies:
     estraverse "^4.1.0"
 
+estraverse@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
 estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
@@ -3190,7 +3258,14 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.1"
     readable-stream "^2.0.4"
 
-follow-redirects@^1.0.0, follow-redirects@^1.3.0:
+follow-redirects@1.5.10:
+  version "1.5.10"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
+  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
+  dependencies:
+    debug "=3.1.0"
+
+follow-redirects@^1.0.0:
   version "1.5.9"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.9.tgz#c9ed9d748b814a39535716e531b9196a845d89c6"
   dependencies:
@@ -3304,6 +3379,16 @@ fsevents@^1.0.0, fsevents@^1.1.3, fsevents@^1.2.2:
     nan "^2.9.2"
     node-pre-gyp "^0.10.0"
 
+fstream@1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
+  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    inherits "~2.0.0"
+    mkdirp ">=0.5 0"
+    rimraf "2"
+
 fstream@^1.0.0, fstream@^1.0.2:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171"
@@ -3494,6 +3579,17 @@ handle-thing@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
 
+handlebars@4.0.14:
+  version "4.0.14"
+  resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.14.tgz#88de711eb693a5b783ae06065f9b91b0dd373a71"
+  integrity sha512-E7tDoyAA8ilZIV3xDJgl18sX3M8xB9/fMw8+mfW4msLW8jlX97bAnWgT3pmaNXuvzIEgSBMnAHfuXsB2hdzfow==
+  dependencies:
+    async "^2.5.0"
+    optimist "^0.6.1"
+    source-map "^0.6.1"
+  optionalDependencies:
+    uglify-js "^3.1.4"
+
 handlebars@^4.0.3:
   version "4.0.12"
   resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5"
@@ -3942,6 +4038,11 @@ is-buffer@^1.1.5:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
 
+is-buffer@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725"
+  integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==
+
 is-builtin-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe"
@@ -4650,7 +4751,15 @@ js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
 
-js-yaml@3.12.0, js-yaml@^3.10.0, js-yaml@^3.4.3, js-yaml@^3.7.0:
+js-yaml@3.13.1:
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
+  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+  dependencies:
+    argparse "^1.0.7"
+    esprima "^4.0.0"
+
+js-yaml@^3.10.0, js-yaml@^3.4.3, js-yaml@^3.7.0:
   version "3.12.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1"
   dependencies:
@@ -4854,6 +4963,11 @@ jszip@3.1.5:
     pako "~1.0.2"
     readable-stream "~2.0.6"
 
+just-extend@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
+  integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==
+
 keycode@^2.1.9:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
@@ -4972,11 +5086,16 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
+lodash-es@4.17.14:
+  version "4.17.14"
+  resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.14.tgz#12a95a963cc5955683cee3b74e85458954f37ecc"
+  integrity sha512-7zchRrGa8UZXjD/4ivUWP1867jDkhzTG2c/uj739utSd7O/pFFdxspCemIFKEEjErbcqRzn8nKnGsi7mvTgRPA==
+
 lodash-es@^4.17.10, lodash-es@^4.17.5, lodash-es@^4.2.1:
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0"
 
-lodash._reinterpolate@~3.0.0:
+lodash._reinterpolate@^3.0.0, lodash._reinterpolate@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
 
@@ -5028,6 +5147,11 @@ lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
+lodash.mergewith@4.6.2:
+  version "4.6.2"
+  resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
+  integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
+
 lodash.mergewith@^4.6.0:
   version "4.6.1"
   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
@@ -5040,6 +5164,14 @@ lodash.startswith@^4.2.1:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c"
 
+lodash.template@4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
+  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+    lodash.templatesettings "^4.0.0"
+
 lodash.template@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0"
@@ -5057,14 +5189,44 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-lodash@4.17.11, "lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.10:
+lodash@4.17.13:
+  version "4.17.13"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.13.tgz#0bdc3a6adc873d2f4e0c4bac285df91b64fc7b93"
+  integrity sha512-vm3/XWXfWtRua0FkUyEHBZy8kCPjErNBT9fJx8Zvs+U6zjqPbTUOpkaoum3O5uiA8sm+yNMHXfYkTUHFoMxFNA==
+
+"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.10:
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
 
+lodash@^4.17.15:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+log-symbols@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
+  dependencies:
+    chalk "^2.0.1"
+
 loglevel@^1.4.1:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa"
 
+loglevelnext@^1.0.1:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/loglevelnext/-/loglevelnext-1.0.5.tgz#36fc4f5996d6640f539ff203ba819641680d75a2"
+  integrity sha512-V/73qkPuJmx4BcBF19xPBr+0ZRVBhc4POxvZTZdMeXpJ4NItXSJ/MSwuFT0kQJlCbXvdlZoQQ/418bS1y9Jh6A==
+  dependencies:
+    es6-symbol "^3.1.1"
+    object.assign "^4.1.0"
+
+lolex@^4.0.1, lolex@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-4.2.0.tgz#ddbd7f6213ca1ea5826901ab1222b65d714b3cd7"
+  integrity sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==
+
 longest@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
@@ -5075,7 +5237,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-loud-rejection@^1.0.0:
+loud-rejection@^1.0.0, loud-rejection@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
   dependencies:
@@ -5109,6 +5271,13 @@ makeerror@1.0.x:
   dependencies:
     tmpl "1.0.x"
 
+map-age-cleaner@^0.1.1:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a"
+  integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==
+  dependencies:
+    p-defer "^1.0.0"
+
 map-cache@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
@@ -5143,6 +5312,15 @@ media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
 
+mem@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/mem/-/mem-4.0.0.tgz#6437690d9471678f6cc83659c00cbafcd6b0cdaf"
+  integrity sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA==
+  dependencies:
+    map-age-cleaner "^0.1.1"
+    mimic-fn "^1.0.0"
+    p-is-promise "^1.1.0"
+
 mem@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
@@ -5248,6 +5426,11 @@ mime@^1.4.1, mime@^1.5.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
 
+mime@^2.1.0:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
+  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+
 mimic-fn@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
@@ -5417,6 +5600,17 @@ next-tick@1:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
 
+nise@^1.4.10:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-1.5.2.tgz#b6d29af10e48b321b307e10e065199338eeb2652"
+  integrity sha512-/6RhOUlicRCbE9s+94qCUsyE+pKlVJ5AhIv+jEE7ESKwnbXqulKZ1FYU+XAtHHWE9TinYvAxDUJAb912PwPoWA==
+  dependencies:
+    "@sinonjs/formatio" "^3.2.1"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    lolex "^4.1.0"
+    path-to-regexp "^1.7.0"
+
 no-case@^2.2.0:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
@@ -5837,10 +6031,20 @@ osenv@0, osenv@^0.1.4:
     os-homedir "^1.0.0"
     os-tmpdir "^1.0.0"
 
+p-defer@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
+  integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
 
+p-is-promise@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e"
+  integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=
+
 p-limit@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
@@ -7397,6 +7601,16 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
 
+set-value@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
+  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-extendable "^0.1.1"
+    is-plain-object "^2.0.3"
+    split-string "^3.0.1"
+
 set-value@^0.4.3:
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
@@ -7461,6 +7675,19 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
+sinon@7.3:
+  version "7.3.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.3.2.tgz#82dba3a6d85f6d2181e1eca2c10d8657c2161f28"
+  integrity sha512-thErC1z64BeyGiPvF8aoSg0LEnptSaWE7YhdWWbWXgelOyThent7uKOnnEh9zBxDbKixtr5dEko+ws1sZMuFMA==
+  dependencies:
+    "@sinonjs/commons" "^1.4.0"
+    "@sinonjs/formatio" "^3.2.1"
+    "@sinonjs/samsam" "^3.3.1"
+    diff "^3.5.0"
+    lolex "^4.0.1"
+    nise "^1.4.10"
+    supports-color "^5.5.0"
+
 slash@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
@@ -7492,6 +7719,18 @@ snapdragon@^0.8.1:
     source-map-resolve "^0.5.0"
     use "^3.1.0"
 
+sockjs-client@1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.4.tgz#5babe386b775e4cf14e7520911452654016c8b12"
+  integrity sha1-W6vjhrd15M8U51IJEUUmVAFsixI=
+  dependencies:
+    debug "^2.6.6"
+    eventsource "0.1.6"
+    faye-websocket "~0.11.0"
+    inherits "^2.0.1"
+    json3 "^3.3.2"
+    url-parse "^1.1.8"
+
 sockjs-client@1.1.5:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.5.tgz#1bb7c0f7222c40f42adf14f4442cbd1269771a83"
@@ -7808,7 +8047,7 @@ supports-color@^4.2.1:
   dependencies:
     has-flag "^2.0.0"
 
-supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0:
+supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   dependencies:
@@ -8052,6 +8291,11 @@ ts-loader@^2.3.7:
     loader-utils "^1.0.2"
     semver "^5.0.1"
 
+ts-mock-imports@1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/ts-mock-imports/-/ts-mock-imports-1.2.6.tgz#5a98a398c3eadb7f75b6904984bb0ba5f3fbb912"
+  integrity sha512-rZjsIEBWx9a3RGUo4Rhj/hzEGB4GPWJx46fls9EJf4UBsf5SxS2qiozf6dQp0Ym/9LC5MArlXZbZ+93wJzAmjA==
+
 tsconfig-paths-webpack-plugin@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-2.0.0.tgz#7652dc684bb3206c8e7e446831ca01cbf4d11772"
@@ -8077,6 +8321,16 @@ tslint-config-prettier@^1.10.0:
   version "1.15.0"
   resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.15.0.tgz#76b9714399004ab6831fdcf76d89b73691c812cf"
 
+tslint-etc@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/tslint-etc/-/tslint-etc-1.6.0.tgz#99d1ddf79dc5eaefa14ddbd94742197d0ba0ff45"
+  integrity sha512-+7YkUcHhRowg3odIKV8V4FtrHyf2q/jlabSvn4KjMV+Uansncdq10s0MhFPFCYrSv6Eyhh0vUyu3+T/PcuDO/g==
+  dependencies:
+    "@phenomnomnominal/tsquery" "^3.0.0"
+    tslib "^1.8.0"
+    tsutils "^3.0.0"
+    tsutils-etc "^1.0.0"
+
 tslint-react@^3.2.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/tslint-react/-/tslint-react-3.6.0.tgz#7f462c95c4a0afaae82507f06517ff02942196a1"
@@ -8100,12 +8354,24 @@ tslint@^5.7.0:
     tslib "^1.8.0"
     tsutils "^2.27.2"
 
+tsutils-etc@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/tsutils-etc/-/tsutils-etc-1.1.0.tgz#82ce1c92da29e07d3cde95692d5c5e8dbdc92fd0"
+  integrity sha512-pJlLtLmQPUyGHqY/Pq6EGnpGmQCnnTDZetQ7eWkeQ5xaw4GtfcR1Zt7HMKFHGDDp53HzQfbqQ+7ps6iJbfa9Hw==
+
 tsutils@^2.13.1, tsutils@^2.27.2:
   version "2.29.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99"
   dependencies:
     tslib "^1.8.1"
 
+tsutils@^3.0.0:
+  version "3.17.1"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
+  integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==
+  dependencies:
+    tslib "^1.8.1"
+
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -8126,6 +8392,11 @@ type-check@~0.3.2:
   dependencies:
     prelude-ls "~1.1.2"
 
+type-detect@4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
 type-is@~1.6.16:
   version "1.6.16"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"
@@ -8292,6 +8563,11 @@ urix@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
 
+url-join@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7"
+  integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==
+
 url-loader@0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7"
@@ -8352,6 +8628,11 @@ uuid@3.3.2, uuid@^3.0.1, uuid@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
 
+uuid@^3.1.0:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
+  integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
+
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@@ -8445,6 +8726,19 @@ webpack-dev-middleware@1.12.2:
     range-parser "^1.0.3"
     time-stamp "^2.0.0"
 
+webpack-dev-middleware@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.0.1.tgz#7ffd6d0192883c83d3f262e8d7dec822493c6166"
+  integrity sha512-JCturcEZNGA0KHEpOJVRTC/VVazTcPfpR9c1Au6NO9a+jxCRchMi87Qe7y3JeOzc0v5eMMKpuGBnPdN52NA+CQ==
+  dependencies:
+    loud-rejection "^1.6.0"
+    memory-fs "~0.4.1"
+    mime "^2.1.0"
+    path-is-absolute "^1.0.0"
+    range-parser "^1.0.3"
+    url-join "^4.0.0"
+    webpack-log "^1.0.1"
+
 webpack-dev-server@2.11.3:
   version "2.11.3"
   resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.11.3.tgz#3fd48a402164a6569d94d3d17f131432631b4873"
@@ -8477,6 +8771,50 @@ webpack-dev-server@2.11.3:
     webpack-dev-middleware "1.12.2"
     yargs "6.6.0"
 
+webpack-dev-server@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.1.tgz#3c0fdd1ba3b50ebc79858a0e6b9ccdd1565b0c24"
+  integrity sha512-u5lz6REb3+KklgSIytUIOrmWgnpgFmfj/+I+GBXurhEoCsHXpG9twk4NO3bsu72GC9YtxIsiavjfRdhmNt0A/A==
+  dependencies:
+    ansi-html "0.0.7"
+    array-includes "^3.0.3"
+    bonjour "^3.5.0"
+    chokidar "^2.0.0"
+    compression "^1.5.2"
+    connect-history-api-fallback "^1.3.0"
+    debug "^3.1.0"
+    del "^3.0.0"
+    express "^4.16.2"
+    html-entities "^1.2.0"
+    http-proxy-middleware "~0.17.4"
+    import-local "^1.0.0"
+    internal-ip "1.2.0"
+    ip "^1.1.5"
+    killable "^1.0.0"
+    loglevel "^1.4.1"
+    opn "^5.1.0"
+    portfinder "^1.0.9"
+    selfsigned "^1.9.1"
+    serve-index "^1.7.2"
+    sockjs "0.3.19"
+    sockjs-client "1.1.4"
+    spdy "^3.4.1"
+    strip-ansi "^3.0.0"
+    supports-color "^5.1.0"
+    webpack-dev-middleware "3.0.1"
+    webpack-log "^1.1.2"
+    yargs "9.0.1"
+
+webpack-log@^1.0.1, webpack-log@^1.1.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-1.2.0.tgz#a4b34cda6b22b518dbb0ab32e567962d5c72a43d"
+  integrity sha512-U9AnICnu50HXtiqiDxuli5gLB5PGBo7VvcHx36jRZHwK4vzOYLbImqT4lwWwoMHdQWwEKw736fCHEekokTEKHA==
+  dependencies:
+    chalk "^2.1.0"
+    log-symbols "^2.1.0"
+    loglevelnext "^1.0.1"
+    uuid "^3.1.0"
+
 webpack-manifest-plugin@1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-1.3.2.tgz#5ea8ee5756359ddc1d98814324fe43496349a7d4"
@@ -8721,6 +9059,25 @@ yargs@6.6.0:
     y18n "^3.2.1"
     yargs-parser "^4.2.0"
 
+yargs@9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-9.0.1.tgz#52acc23feecac34042078ee78c0c007f5085db4c"
+  integrity sha1-UqzCP+7Kw0BCB47njAwAf1CF20w=
+  dependencies:
+    camelcase "^4.1.0"
+    cliui "^3.2.0"
+    decamelize "^1.1.1"
+    get-caller-file "^1.0.1"
+    os-locale "^2.0.0"
+    read-pkg-up "^2.0.0"
+    require-directory "^2.1.1"
+    require-main-filename "^1.0.1"
+    set-blocking "^2.0.0"
+    string-width "^2.0.0"
+    which-module "^2.0.0"
+    y18n "^3.2.1"
+    yargs-parser "^7.0.0"
+
 yargs@^10.0.3:
   version "10.1.2"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-10.1.2.tgz#454d074c2b16a51a43e2fb7807e4f9de69ccb5c5"