From: Peter Amstutz Date: Thu, 12 Dec 2019 15:57:44 +0000 (-0500) Subject: Merge branch '15256-removing-files-during-upload' X-Git-Tag: 2.0.0~22 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/f0a64666816383d2641d5fa7ea22019441ac4464?hp=e586962e68a264bc6a8cf2830475e4fb2c910046 Merge branch '15256-removing-files-during-upload' refs #15256 Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- diff --git a/.gitignore b/.gitignore index c18f27f6..45df030d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ .env.development.local .env.test.local .env.production.local +.npm.local npm-debug.log* yarn-debug.log* diff --git a/.licenseignore b/.licenseignore index 09914c90..7ac3c836 100644 --- a/.licenseignore +++ b/.licenseignore @@ -12,3 +12,4 @@ public/* .licenseignore .yarnrc .npmrc +src/lib/cwl-svg/* diff --git a/README.md b/README.md index 425d1787..e2e14ce4 100644 --- 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 diff --git a/package.json b/package.json index 68d23bfa..1059a07d 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,22 @@ "@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", @@ -47,9 +53,14 @@ "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", diff --git a/public/vocabulary-example.json b/public/vocabulary-example.json index b227dc23..59d4de7a 100644 --- a/public/vocabulary-example.json +++ b/public/vocabulary-example.json @@ -1,32 +1,213 @@ { - "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 diff --git a/src/common/config.ts b/src/common/config.ts index 71b7774c..7d974342 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -4,137 +4,199 @@ 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(CONFIG_URL + "?nocache=" + (new Date()).getTime()) + .get(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime()) .then(response => response.data) - .catch(() => Promise.resolve(getDefaultConfig())) - .then(config => Axios - .get(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(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 => ({ + 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 => ({ - 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()}`; diff --git a/src/common/formatters.ts b/src/common/formatters.ts index 377e78e4..819875be 100644 --- a/src/common/formatters.ts +++ b/src/common/formatters.ts @@ -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 index 00000000..dc96f929 --- /dev/null +++ b/src/common/getuser.ts @@ -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 index 00000000..eca24c75 --- /dev/null +++ b/src/common/regexp.ts @@ -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 diff --git a/src/common/url.ts b/src/common/url.ts index 1824f26a..9789b65e 100644 --- a/src/common/url.ts +++ b/src/common/url.ts @@ -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(); +} diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx index 4b19b771..e01673b7 100644 --- a/src/components/autocomplete/autocomplete.tsx +++ b/src/components/autocomplete/autocomplete.tsx @@ -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'; diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 444ac75e..20782330 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -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 ( + {isFirstItem ? null : } ); diff --git a/src/views-components/main-app-bar/main-app-bar.tsx b/src/views-components/main-app-bar/main-app-bar.tsx index 475b29e1..ce1cab4c 100644 --- a/src/views-components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -32,6 +32,7 @@ interface MainAppBarDataProps { buildInfo?: string; children?: ReactNode; uuidPrefix: string; + siteBanner: string; } export type MainAppBarProps = MainAppBarDataProps & WithStyles; @@ -44,7 +45,7 @@ export const MainAppBar = withStyles(styles)( - arvados workbench ({props.uuidPrefix}) + ({props.uuidPrefix}) {props.buildInfo} diff --git a/src/views-components/process-input-dialog/process-input-dialog.tsx b/src/views-components/process-input-dialog/process-input-dialog.tsx index edb4bc68..a2d59407 100644 --- a/src/views-components/process-input-dialog/process-input-dialog.tsx +++ b/src/views-components/process-input-dialog/process-input-dialog.tsx @@ -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) => @@ -31,17 +33,18 @@ export const ProcessInputDialog = withDialog(PROCESS_INPUT_DIALOG_NAME)( ); -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 diff --git a/src/views-components/project-properties-dialog/project-properties-dialog.tsx b/src/views-components/project-properties-dialog/project-properties-dialog.tsx index caedd4e6..7a4cfba6 100644 --- a/src/views-components/project-properties-dialog/project-properties-dialog.tsx +++ b/src/views-components/project-properties-dialog/project-properties-dialog.tsx @@ -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(deleteProjectProperty(key)) + handleDelete: (key: string) => dispatch(deleteProjectProperty(key)), }); type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles; @@ -53,12 +53,12 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro Properties - {project && project.properties && - Object.keys(project.properties).map(k => { - return + handleDelete(k)} - label={`${k}: ${project.properties[k]}`} />; - }) + key={k} className={classes.tag} + propKey={k} propValue={project.properties[k]} />) } @@ -70,4 +70,5 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro -))); \ 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 index 215c31b7..00000000 --- a/src/views-components/project-tree-picker/project-tree-picker.tsx +++ /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, '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(toggleItemOpen(id, status, pickerId)); - }, - toggleItemSelection: (_, { id }, pickerId) => { - dispatch(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(loadProjectTreePickerProjects(id)); - } else if (pickerId === TreePickerId.FAVORITES) { - dispatch(loadFavoriteTreePickerProjects(id === services.authService.getUuid() ? '' : id)); - } else if (pickerId === TreePickerId.PUBLIC_FAVORITES) { - dispatch(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) => -
- - Select a project - -
- - - - -
-
); - -const getProjectPickerIcon = (item: TreeItem) => { - 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) => - ; - -export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) => -
- - {props.meta.dirty && props.meta.error && - - {props.meta.error} - } -
; - -const handleChange = (props: WrappedFieldProps) => - (_: any, { id }: TreeItem) => - props.input.onChange(id); - -export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) => -
- - {props.meta.dirty && props.meta.error && - - {props.meta.error} - } -
; \ 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 index 18efdaf8..00000000 --- a/src/views-components/project-tree/project-tree.test.tsx +++ /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 = { - data: mockProjectResource(), - id: "3", - open: true, - active: true, - status: TreeItemStatus.PENDING - }; - const wrapper = mount(); - - expect(wrapper.find(ListItemIcon)).toHaveLength(2); - }); - - it("should render Collapse", () => { - const project: Array> = [ - { - 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(); - - expect(wrapper.find(Collapse)).toHaveLength(1); - }); - - it("should render CircularProgress", () => { - const project: TreeItem = { - data: mockProjectResource(), - id: "3", - open: false, - active: true, - status: TreeItemStatus.PENDING - }; - const wrapper = mount(); - - 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 index fe808af5..00000000 --- a/src/views-components/project-tree/project-tree.tsx +++ /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 = (theme: ArvadosTheme) => ({ - root: { - marginLeft: `${theme.spacing.unit * 1.5}px`, - } -}); - -export interface ProjectTreeProps { - projects: Array>; - toggleOpen: (event: React.MouseEvent, item: TreeItem) => void; - toggleActive: (event: React.MouseEvent, item: TreeItem) => void; - onContextMenu: (event: React.MouseEvent, item: TreeItem) => void; -} - -export const ProjectTree = withStyles(styles)( - class ProjectTreeGeneric extends React.Component & WithStyles> { - render(): ReactElement { - const { classes, projects, toggleOpen, toggleActive, onContextMenu } = this.props; - return ( -
- ) => - - } /> -
- ); - } - } -); diff --git a/src/views-components/projects-tree-picker/projects-tree-picker.tsx b/src/views-components/projects-tree-picker/projects-tree-picker.tsx index f4969f5c..10bb5eb8 100644 --- a/src/views-components/projects-tree-picker/projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/projects-tree-picker.tsx @@ -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 index 00000000..a8ab05f6 --- /dev/null +++ b/src/views-components/projects-tree-picker/tree-picker-field.tsx @@ -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) => +
+ + {props.meta.dirty && props.meta.error && + + {props.meta.error} + } +
; + +const handleChange = (props: WrappedFieldProps) => + (_: any, { id }: TreeItem) => + props.input.onChange(id); + +export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) => +
+ + {props.meta.dirty && props.meta.error && + + {props.meta.error} + } +
; \ 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 index 00000000..c51a8d8e --- /dev/null +++ b/src/views-components/resource-properties-form/property-chip.tsx @@ -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 ( + onCopy("Copied to clipboard")}> + + + ); + } +); diff --git a/src/views-components/resource-properties-form/property-field-common.tsx b/src/views-components/resource-properties-form/property-field-common.tsx index 028c46b9..e802ad5c 100644 --- a/src/views-components/resource-properties-form/property-field-common.tsx +++ b/src/views-components/resource-properties-form/property-field-common.tsx @@ -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)); + } + }; diff --git a/src/views-components/resource-properties-form/property-key-field.tsx b/src/views-components/resource-properties-form/property-key-field.tsx index 3fb2d377..1f921188 100644 --- a/src/views-components/resource-properties-form/property-key-field.tsx +++ b/src/views-components/resource-properties-form/property-key-field.tsx @@ -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) => ); - -export const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) => - ; + validate={skipValidation ? undefined : getValidation(vocabulary)} /> +); + +const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) => + ( + + )} />; 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); +}; diff --git a/src/views-components/resource-properties-form/property-value-field.tsx b/src/views-components/resource-properties-form/property-value-field.tsx index 13dcfeb5..99745199 100644 --- a/src/views-components/resource-properties-form/property-value-field.tsx +++ b/src/views-components/resource-properties-form/property-value-field.tsx @@ -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) => ); - -export const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) => - ; + validate={skipValidation ? undefined : getValidation(props)} + {...props} /> +); + +const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) => + ( + + )} />; 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); }; diff --git a/src/views-components/resource-properties-form/resource-properties-form.tsx b/src/views-components/resource-properties-form/resource-properties-form.tsx index 6c2e025a..db40e4a7 100644 --- a/src/views-components/resource-properties-form/resource-properties-form.tsx +++ b/src/views-components/resource-properties-form/resource-properties-form.tsx @@ -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 & WithStyles; diff --git a/src/views-components/search-bar/search-bar-advanced-properties-view.tsx b/src/views-components/search-bar/search-bar-advanced-properties-view.tsx index d4044f95..eb049b76 100644 --- a/src/views-components/search-bar/search-bar-advanced-properties-view.tsx +++ b/src/views-components/search-bar/search-bar-advanced-properties-view.tsx @@ -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; -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(changeAdvanceFormProperty('properties', propertyValues)); + dispatch(changeAdvancedFormProperty('properties', propertyValues)); }, - addProp: (propertyValues: PropertyValue) => { - dispatch(updateAdvanceFormProperties(propertyValues)); - dispatch(changeAdvanceFormProperty('key')); - dispatch(changeAdvanceFormProperty('value')); + setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => { + dispatch(changeAdvancedFormProperty( + 'properties', + [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue] + )); + dispatch(resetAdvancedFormProperty('key')); + dispatch(resetAdvancedFormProperty('value')); + dispatch(resetAdvancedFormProperty('keyID')); + dispatch(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) => Properties @@ -83,7 +93,7 @@ export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispa - - - - ))); +export const InactivePanel = connect((state: RootState) => ({ + inactivePageText: state.auth.config.clusterConfig.Workbench.InactivePageHTML +}), mapDispatchToProps)(withStyles(styles)((({ classes, startLinking, inactivePageText }: InactivePanelProps) => + + + +
+ + + + + If you would like to use this login to access another account click "Link Account". + + + + + + +))); diff --git a/src/views/keep-service-panel/keep-service-panel-root.tsx b/src/views/keep-service-panel/keep-service-panel-root.tsx index 8c266b61..f2a7ed1c 100644 --- a/src/views/keep-service-panel/keep-service-panel-root.tsx +++ b/src/views/keep-service-panel/keep-service-panel-root.tsx @@ -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'; diff --git a/src/views/link-account-panel/link-account-panel-root.tsx b/src/views/link-account-panel/link-account-panel-root.tsx index 0eb494e6..98d19ace 100644 --- a/src/views/link-account-panel/link-account-panel-root.tsx +++ b/src/views/link-account-panel/link-account-panel-root.tsx @@ -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 = (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({user.email} ({user.username}, {user.uuid})); if (showCluster) { - const homeCluster = user.uuid.substr(0,5); + const homeCluster = user.uuid.substr(0, 5); disp.push( hosted on cluster {homeCluster} and ); } if (showCreatedAt) { @@ -67,116 +69,128 @@ function isLocalUser(uuid: string, localCluster: string) { type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles; -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 - { isProcessing && - - Loading user info. Please wait. - - - - - } - { !isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser &&
- { isLocalUser(targetUser.uuid, localCluster) ? - - - You are currently logged in as {displayUser(targetUser, true)} - - - You can link Arvados accounts. After linking, either login will take you to the same account. - + {isProcessing && + + Loading user info. Please wait. + + + - - - - - - + } + {!isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser &&
+ {isLocalUser(targetUser.uuid, localCluster) ? + + + You are currently logged in as {displayUser(targetUser, true)} + + + You can link Arvados accounts. After linking, either login will take you to the same account. + - - { hasRemoteHosts && selectedCluster && - - You can also link {displayUser(targetUser, false)} with an account from a remote cluster. + + + + + + + - - Please select the cluster that hosts the account you want to link with: - setSelectedCluster(event.target.value)}> - {Object.keys(remoteHosts).map((k) => k !== localCluster ? : null)} + {Object.keys(remoteHostsConfig).map((k) => k !== localCluster ? : null)} - - - - } - : - - - - You are currently logged in as {displayUser(targetUser, true, true)} + + + + } + : + + + + You are currently logged in as {displayUser(targetUser, true, true)} + + {targetUser.isActive ? + (loginCluster === "" ? + <> + 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 + {localCluster} cluster as user {targetUser.email} + from {targetUser.uuid.substr(0, 5)}. + + + + + : Please visit cluster + {loginCluster} + to perform account linking. + ) + : + 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 {localCluster} + cluster to this one. + } + + } +
} + {!isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser && + + {status === LinkAccountPanelStatus.LINKING && + + Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}. + + {(isLocalUser(targetUser.uuid, localCluster)) && + After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}. + } + + Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}. + + {!isLocalUser(targetUser.uuid, localCluster) && + You can access {userToLink.email} data by logging into {localCluster} with the {targetUser.email} account. + } + } + {error === LinkAccountPanelError.NON_ADMIN && + Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}. + } + {error === LinkAccountPanelError.SAME_USER && + Cannot link {displayUser(targetUser)} to the same account. + } + {error === LinkAccountPanelError.INACTIVE && + Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}. + } + + + + + + + - {targetUser.isActive ? <> - 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 {localCluster} cluster as user {targetUser.email} from {targetUser.uuid.substr(0,5)}. - - - - - : - 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 {localCluster} cluster to this one. - } - -
} -
} - { !isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser && - - { status === LinkAccountPanelStatus.LINKING && - - Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}. - - { (isLocalUser(targetUser.uuid, localCluster)) && - After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}. - } - - Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}. - - { !isLocalUser(targetUser.uuid, localCluster) && - You can access {userToLink.email} data by logging into {localCluster} with the {targetUser.email} account. - } - } - { error === LinkAccountPanelError.NON_ADMIN && - Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}. - } - { error === LinkAccountPanelError.SAME_USER && - Cannot link {displayUser(targetUser)} to the same account. - } - { error === LinkAccountPanelError.INACTIVE && - Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}. - } - - - - - - - - - } -
-
; -}); \ No newline at end of file + } + + ; + }); diff --git a/src/views/link-account-panel/link-account-panel.tsx b/src/views/link-account-panel/link-account-panel.tsx index c3ad51cf..78b7efd2 100644 --- a/src/views/link-account-panel/link-account-panel.tsx +++ b/src/views/link-account-panel/link-account-panel.tsx @@ -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(startLinking(type)), cancelLinking: () => dispatch(cancelLinking(true)), linkAccount: () => dispatch(linkAccount()), - setSelectedCluster: (selectedCluster: string) => dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({selectedCluster})) + setSelectedCluster: (selectedCluster: string) => dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster })) }); export const LinkAccountPanel = connect(mapStateToProps, mapDispatchToProps)(LinkAccountPanelRoot); diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx index 41a17bf9..6fe3eee2 100644 --- a/src/views/login-panel/login-panel.tsx +++ b/src/views/login-panel/login-panel.tsx @@ -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 = (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 = (theme: ArvadosTheme) => ({ type LoginPanelProps = DispatchProp & WithStyles & { 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) => - - Welcome to the Arvados Workbench - - - 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. - - - If you have never used Arvados Workbench before, logging in for the first time will automatically create a new account. - - - IMPORTANT: Please keep in mind to store exploratory data only but not any information used for clinical decision making. - - - Arvados Workbench uses your name and email address only for identification, and does not retrieve any other personal information from Google. - + +
+ + {Object.keys(remoteHosts).length > 1 && loginCluster === "" && - {Object.keys(remoteHosts).length > 1 &&