Merge branch 'origin/master' into 14280-query-language
authorDaniel Kos <daniel.kos@contractors.roche.com>
Fri, 30 Nov 2018 07:22:14 +0000 (08:22 +0100)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Fri, 30 Nov 2018 07:22:14 +0000 (08:22 +0100)
# Conflicts:
# package.json

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

31 files changed:
README.md
package.json
src/common/config.ts
src/components/autocomplete/autocomplete.tsx
src/components/progress-button/progress-button.tsx [new file with mode: 0644]
src/index.tsx
src/models/user.ts
src/models/vocabulary.ts [new file with mode: 0644]
src/services/auth-service/auth-service.ts
src/services/services.ts
src/services/vocabulary-service/vocabulary-service.ts [new file with mode: 0644]
src/store/auth/auth-actions.test.ts
src/store/auth/auth-reducer.test.ts
src/store/search-results-panel/search-results-panel-actions.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/vocabulary/vocabulary-actions.ts [new file with mode: 0644]
src/store/vocabulary/vocabulary-selctors.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/project-properties-dialog/project-properties-form.tsx
src/views-components/resource-properties-form/property-field-common.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-key-field.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-value-field.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/resource-properties-form.tsx [new file with mode: 0644]
src/views/collection-panel/collection-tag-form.tsx
src/views/run-process-panel/inputs/boolean-input.tsx
src/views/run-process-panel/inputs/directory-input.tsx
src/views/run-process-panel/inputs/enum-input.tsx
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/inputs/float-input.tsx
src/views/run-process-panel/inputs/int-input.tsx
src/views/run-process-panel/inputs/string-input.tsx

index 998d424662ac4cb69fb75b89904d9955fe5bc25d..ea9bc02fc1f0f038b016f5fd8f5ade2ab38ad2f9 100644 (file)
--- a/README.md
+++ b/README.md
@@ -40,7 +40,8 @@ The app will fetch runtime configuration when starting. By default it will try t
 Currently this configuration schema is supported:
 ```
 {
-    "API_HOST": "string"
+    "API_HOST": "string",
+    "VOCABULARY_URL": "string"
 }
 ```
 
index 90fca4f28f0824a1eb0937f87c4de7a2b44438e3..1c066db8c1776fcf3238bf0a8e06190851b9e224 100644 (file)
@@ -47,8 +47,9 @@
   "scripts": {
     "start": "react-scripts-ts start",
     "build": "REACT_APP_BUILD_NUMBER=$BUILD_NUMBER REACT_APP_GIT_COMMIT=$GIT_COMMIT react-scripts-ts build",
+    "build-local": "react-scripts-ts build",
     "test": "CI=true react-scripts-ts test --env=jsdom",
-    "test:watch": "react-scripts-ts test --env=jsdom",
+    "test-local": "react-scripts-ts test --env=jsdom",
     "eject": "react-scripts-ts eject",
     "lint": "tslint src/** -t verbose",
     "build-css": "node-sass-chokidar src/ -o src/",
index 1ab73294b01bc9c742c8b52d17bb1c418d080dc9..c74277e42cc1ff78a9499e688f9f3bc171ca2835 100644 (file)
@@ -49,6 +49,7 @@ export interface Config {
     version: string;
     websocketUrl: string;
     workbenchUrl: string;
+    vocabularyUrl: string;
 }
 
 export const fetchConfig = () => {
@@ -58,7 +59,10 @@ export const fetchConfig = () => {
         .catch(() => Promise.resolve(getDefaultConfig()))
         .then(config => Axios
             .get<Config>(getDiscoveryURL(config.API_HOST))
-            .then(response => ({ config: response.data, apiHost: config.API_HOST })));
+            .then(response => ({ 
+                config: {...response.data, vocabularyUrl: config.VOCABULARY_URL }, 
+                apiHost: config.API_HOST, 
+            })));
 
 };
 
@@ -105,15 +109,18 @@ export const mockConfig = (config: Partial<Config>): Config => ({
     version: '',
     websocketUrl: '',
     workbenchUrl: '',
+    vocabularyUrl: '',
     ...config
 });
 
 interface ConfigJSON {
     API_HOST: string;
+    VOCABULARY_URL: string;
 }
 
 const getDefaultConfig = (): ConfigJSON => ({
     API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "",
+    VOCABULARY_URL: "",
 });
 
 const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/discovery/v1/apis/arvados/v1/rest`;
index 85704c357ca127ff240ba5a08173166c1c9054e4..7da4ba4a30845a85713439624c8f70fcb052c99a 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List } from '@material-ui/core';
+import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, 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';
@@ -13,6 +13,8 @@ export interface AutocompleteProps<Item, Suggestion> {
     value: string;
     items: Item[];
     suggestions?: Suggestion[];
+    error?: boolean;
+    helperText?: string;
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -38,9 +40,10 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
     render() {
         return (
             <RootRef rootRef={this.containerRef}>
-                <FormControl fullWidth>
+                <FormControl fullWidth error={this.props.error}>
                     {this.renderLabel()}
                     {this.renderInput()}
+                    {this.renderHelperText()}
                     {this.renderSuggestions()}
                 </FormControl>
             </RootRef>
@@ -64,12 +67,16 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
         />;
     }
 
+    renderHelperText(){
+        return <FormHelperText>{this.props.helperText}</FormHelperText>;
+    }
+
     renderSuggestions() {
         const { suggestions = [] } = this.props;
         return (
             <Popper
                 open={this.state.suggestionsOpen && suggestions.length > 0}
-                anchorEl={this.containerRef.current}>
+                anchorEl={this.inputRef.current}>
                 <Paper onMouseDown={this.preventBlur}>
                     <List dense style={{ width: this.getSuggestionsWidth() }}>
                         {suggestions.map(
diff --git a/src/components/progress-button/progress-button.tsx b/src/components/progress-button/progress-button.tsx
new file mode 100644 (file)
index 0000000..14286dd
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import Button, { ButtonProps } from '@material-ui/core/Button';
+import { CircularProgress, withStyles } from '@material-ui/core';
+import { CircularProgressProps } from '@material-ui/core/CircularProgress';
+
+interface ProgressButtonProps extends ButtonProps {
+    loading?: boolean;
+    progressProps?: CircularProgressProps;
+}
+
+export const ProgressButton = ({ loading, progressProps, children, disabled, ...props }: ProgressButtonProps) =>
+    <Button {...props} disabled={disabled || loading}>
+        {children}
+        {loading && <Progress {...progressProps} size={getProgressSize(props.size)} />}
+    </Button>;
+
+const Progress = withStyles({
+    root: {
+        position: 'absolute',
+    },
+})(CircularProgress);
+
+const getProgressSize = (size?: 'small' | 'medium' | 'large') => {
+    switch (size) {
+        case 'small':
+            return 16;
+        case 'large':
+            return 24;
+        default:
+            return 20;
+    }
+};
index 88fd2298bbc5a1936f23fb372e0220432b3c32f1..801a56a1d382a3ac589af3bb732a085190567b1c 100644 (file)
@@ -50,6 +50,7 @@ import HTML5Backend from 'react-dnd-html5-backend';
 import { initAdvanceFormProjectsTree } 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 { loadVocabulary } from '~/store/vocabulary/vocabulary-actions';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -88,6 +89,7 @@ fetchConfig()
         store.dispatch(setBuildInfo());
         store.dispatch(setCurrentTokenDialogApiHost(apiHost));
         store.dispatch(setUuidPrefix(config.uuidPrefix));
+        store.dispatch(loadVocabulary);
 
         const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props} />;
         const MainPanelComponent = (props: any) => <MainPanel {...props} />;
@@ -304,3 +306,4 @@ const createSampleProcess = ({ containerRequestService }: ServiceRepository) =>
     });
 };
 
+// force build comment #1
index c2f21e582798dacd5597872696ff7fc1685d62e7..9f9c534763ca86ee40190c2361c89b4e81da2f95 100644 (file)
@@ -10,6 +10,7 @@ export interface User {
     lastName: string;
     uuid: string;
     ownerUuid: string;
+    isAdmin: boolean;
 }
 
 export const getUserFullname = (user?: User) => {
diff --git a/src/models/vocabulary.ts b/src/models/vocabulary.ts
new file mode 100644 (file)
index 0000000..ea23ad2
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isObject, has, every } from 'lodash/fp';
+
+export interface Vocabulary {
+    strict: boolean;
+    tags: Record<string, Tag>;
+}
+
+export interface Tag {
+    strict?: boolean;
+    values?: string[];
+}
+
+const VOCABULARY_VALIDATORS = [
+    isObject,
+    has('strict'),
+    has('tags'),
+];
+
+export const isVocabulary = (value: any) =>
+    every(validator => validator(value), VOCABULARY_VALIDATORS);
\ No newline at end of file
index 50760bb4d8493b5384b1564ca6e936c00001b40e..edc6e24fce752b628a7573899f21af2e6063073b 100644 (file)
@@ -13,6 +13,7 @@ export const USER_FIRST_NAME_KEY = 'userFirstName';
 export const USER_LAST_NAME_KEY = 'userLastName';
 export const USER_UUID_KEY = 'userUuid';
 export const USER_OWNER_UUID_KEY = 'userOwnerUuid';
+export const USER_IS_ADMIN = 'isAdmin';
 
 export interface UserDetailsResponse {
     email: string;
@@ -50,15 +51,20 @@ export class AuthService {
         return localStorage.getItem(USER_OWNER_UUID_KEY) || undefined;
     }
 
+    public getIsAdmin(): boolean {
+        return !!localStorage.getItem(USER_IS_ADMIN);
+    }
+
     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 = localStorage.getItem(USER_UUID_KEY);
-        const ownerUuid = localStorage.getItem(USER_OWNER_UUID_KEY);
+        const uuid = this.getUuid();
+        const ownerUuid = this.getOwnerUuid();
+        const isAdmin = this.getIsAdmin();
 
         return email && firstName && lastName && uuid && ownerUuid
-            ? { email, firstName, lastName, uuid, ownerUuid }
+            ? { email, firstName, lastName, uuid, ownerUuid, isAdmin }
             : undefined;
     }
 
@@ -68,6 +74,7 @@ export class AuthService {
         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));
     }
 
     public removeUser() {
@@ -76,6 +83,7 @@ export class AuthService {
         localStorage.removeItem(USER_LAST_NAME_KEY);
         localStorage.removeItem(USER_UUID_KEY);
         localStorage.removeItem(USER_OWNER_UUID_KEY);
+        localStorage.removeItem(USER_IS_ADMIN);
     }
 
     public login() {
@@ -100,7 +108,8 @@ export class AuthService {
                     firstName: resp.data.first_name,
                     lastName: resp.data.last_name,
                     uuid: resp.data.uuid,
-                    ownerUuid: resp.data.owner_uuid
+                    ownerUuid: resp.data.owner_uuid,
+                    isAdmin: resp.data.is_admin
                 };
             })
             .catch(e => {
index f1ef86b88555a7760a43d90178b8a7a36d796285..b24b1d99a181a8086e9da06bd41684cd5969a907 100644 (file)
@@ -27,6 +27,7 @@ import { PermissionService } from "~/services/permission-service/permission-serv
 import { VirtualMachinesService } from "~/services/virtual-machines-service/virtual-machines-service";
 import { RepositoriesService } from '~/services/repositories-service/repositories-service';
 import { AuthorizedKeysService } from '~/services/authorized-keys-service/authorized-keys-service';
+import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -58,6 +59,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const favoriteService = new FavoriteService(linkService, groupsService);
     const tagService = new TagService(linkService);
     const searchService = new SearchService();
+    const vocabularyService = new VocabularyService(config.vocabularyUrl);
 
     return {
         ancestorsService,
@@ -82,6 +84,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         virtualMachineService,
         webdavClient,
         workflowService,
+        vocabularyService,
     };
 };
 
diff --git a/src/services/vocabulary-service/vocabulary-service.ts b/src/services/vocabulary-service/vocabulary-service.ts
new file mode 100644 (file)
index 0000000..57bdd7c
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+import { Vocabulary } from '~/models/vocabulary';
+
+export class VocabularyService {
+    constructor(
+        private url: string
+    ) { }
+
+    getVocabulary() {
+        return Axios
+            .get<Vocabulary>(this.url)
+            .then(response => response.data);
+    }
+}
index 231c37b4effc6c2587bd4c1289c434cd7b0d1e83..c54438b170c5b1a27c945444644c6f17bfb066fb 100644 (file)
@@ -10,7 +10,8 @@ import {
     USER_FIRST_NAME_KEY,
     USER_LAST_NAME_KEY,
     USER_OWNER_UUID_KEY,
-    USER_UUID_KEY
+    USER_UUID_KEY,
+    USER_IS_ADMIN
 } from "~/services/auth-service/auth-service";
 
 import 'jest-localstorage-mock';
@@ -42,6 +43,7 @@ describe('auth-actions', () => {
         localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
         localStorage.setItem(USER_UUID_KEY, "uuid");
         localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
+        localStorage.setItem(USER_IS_ADMIN, JSON.stringify("false"));
 
         store.dispatch(initAuth());
 
@@ -53,7 +55,8 @@ describe('auth-actions', () => {
                 firstName: "John",
                 lastName: "Doe",
                 uuid: "uuid",
-                ownerUuid: "ownerUuid"
+                ownerUuid: "ownerUuid",
+                isAdmin: true
             }
         });
     });
index 8cde324549008b4790ff4d4595e4bfa46d55a948..a4017db3be7af4b83a69543fdc85d825516ec149 100644 (file)
@@ -29,7 +29,8 @@ describe('auth-reducer', () => {
             firstName: "John",
             lastName: "Doe",
             uuid: "uuid",
-            ownerUuid: "ownerUuid"
+            ownerUuid: "ownerUuid",
+            isAdmin: false
         };
         const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
         expect(state).toEqual({
@@ -58,7 +59,8 @@ describe('auth-reducer', () => {
             firstName: "John",
             lastName: "Doe",
             uuid: "uuid",
-            ownerUuid: "ownerUuid"
+            ownerUuid: "ownerUuid",
+            isAdmin: false
         };
 
         const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
@@ -71,6 +73,7 @@ describe('auth-reducer', () => {
                 lastName: "Doe",
                 uuid: "uuid",
                 ownerUuid: "ownerUuid",
+                isAdmin: false
             }
         });
     });
index 05da5b3e5787e36c1a9b97f128e11f4982994575..f7dc5d458f7fde7eb3af468253d4d816c5e5761f 100644 (file)
@@ -6,11 +6,13 @@ import { Dispatch } from 'redux';
 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';
 
 export const SEARCH_RESULTS_PANEL_ID = "searchResultsPanel";
 export const searchResultsPanelActions = bindDataExplorerActions(SEARCH_RESULTS_PANEL_ID);
 
 export const loadSearchResultsPanel = () =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([{ label: 'Search results' }]));
         dispatch(searchResultsPanelActions.REQUEST_ITEMS());
     };
\ No newline at end of file
index 0e3c76b28ef08f4c0c2a251d421cbc8e00d1aab4..37de6f8c2e9ab19e8a1c28c05ad4a20a8fb7013c 100644 (file)
@@ -56,13 +56,18 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => {
 const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
 
     const dialog = getDialog<string>(getState().dialog, SHARING_DIALOG_NAME);
-
     if (dialog) {
         dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
-        const { items } = await permissionService.listResourcePermissions(dialog.data);
-        dispatch<any>(initializePublicAccessForm(items));
-        await dispatch<any>(initializeManagementForm(items));
-        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        try {
+            const { items } = await permissionService.listResourcePermissions(dialog.data);
+            dispatch<any>(initializePublicAccessForm(items));
+            await dispatch<any>(initializeManagementForm(items));
+            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'You do not have access to share this item', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
+            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        }
     }
 };
 
diff --git a/src/store/vocabulary/vocabulary-actions.ts b/src/store/vocabulary/vocabulary-actions.ts
new file mode 100644 (file)
index 0000000..799cffa
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { isVocabulary } from '~/models/vocabulary';
+
+export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
+    const vocabulary = await vocabularyService.getVocabulary();
+
+    dispatch(propertiesActions.SET_PROPERTY({
+        key: VOCABULARY_PROPERTY_NAME,
+        value: isVocabulary(vocabulary)
+            ? vocabulary
+            : DEFAULT_VOCABULARY,
+    }));
+};
diff --git a/src/store/vocabulary/vocabulary-selctors.ts b/src/store/vocabulary/vocabulary-selctors.ts
new file mode 100644 (file)
index 0000000..d317cb4
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, getProperty } from '~/store/properties/properties';
+import { Vocabulary } from '~/models/vocabulary';
+
+export const VOCABULARY_PROPERTY_NAME = 'vocabulary';
+
+export const DEFAULT_VOCABULARY: Vocabulary = {
+    strict: false,
+    tags: {},
+};
+
+export const getVocabulary = (state: PropertiesState) =>
+    getProperty<Vocabulary>(VOCABULARY_PROPERTY_NAME)(state) || DEFAULT_VOCABULARY;
index 5e1182bb0b3c1daa3f52d3a82bdc8fa0f1b6b548..8c81e3bd1996633ff39eca407e3547a6de0f3cf3 100644 (file)
@@ -60,13 +60,13 @@ export const projectActionSet: ContextMenuActionSet = [[
             dispatch<any>(openMoveProjectDialog(resource));
         }
     },
-    {
-        icon: CopyIcon,
-        name: "Copy to project",
-        execute: (dispatch, resource) => {
-            // add code
-        }
-    },
+    // {
+    //     icon: CopyIcon,
+    //     name: "Copy to project",
+    //     execute: (dispatch, resource) => {
+    //         // add code
+    //     }
+    // },
     {
         icon: DetailsIcon,
         name: "View details",
index 82ae0406954331e8a96c2742754232e3c3238e31..90c8c080d98b2edb522b9a22d52aec9158e83630 100644 (file)
@@ -2,94 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as React from 'react';
-import { reduxForm, Field, reset } from 'redux-form';
-import { compose, Dispatch } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress } from '@material-ui/core';
-import { TagProperty } from '~/models/tag';
-import { TextField } from '~/components/text-field/text-field';
-import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '~/validators/validators';
+import { reduxForm, reset } from 'redux-form';
 import { PROJECT_PROPERTIES_FORM_NAME, createProjectProperty } from '~/store/details-panel/details-panel-action';
+import { ResourcePropertiesForm, ResourcePropertiesFormData } from '~/views-components/resource-properties-form/resource-properties-form';
+import { withStyles } from '@material-ui/core';
 
-type CssRules = 'root' | 'keyField' | 'valueField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+const Form = withStyles(({ spacing }) => ({ container: { marginBottom: spacing.unit * 2 } }))(ResourcePropertiesForm);
 
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        width: '100%',
-        display: 'flex'
-    },
-    keyField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    valueField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    buttonWrapper: {
-        paddingTop: '14px',
-        position: 'relative',
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: -9,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
+export const ProjectPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: PROJECT_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch<any>(createProjectProperty(data));
+        dispatch(reset(PROJECT_PROPERTIES_FORM_NAME));
     }
-});
-
-interface ProjectPropertiesFormDataProps {
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface ProjectPropertiesFormActionProps {
-    handleSubmit: any;
-}
-
-type ProjectPropertiesFormProps = ProjectPropertiesFormDataProps & ProjectPropertiesFormActionProps & WithStyles<CssRules>;
-
-export const ProjectPropertiesForm = compose(
-    reduxForm({
-        form: PROJECT_PROPERTIES_FORM_NAME,
-        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
-            dispatch<any>(createProjectProperty(data));
-            dispatch(reset(PROJECT_PROPERTIES_FORM_NAME));
-        }
-    }),
-    withStyles(styles))(
-        ({ classes, submitting, pristine, invalid, handleSubmit }: ProjectPropertiesFormProps) => 
-            <form onSubmit={handleSubmit} className={classes.root}>
-                <div className={classes.keyField}>
-                    <Field name="key"
-                        disabled={submitting}
-                        component={TextField}
-                        validate={TAG_KEY_VALIDATION}
-                        label="Key" />
-                </div>
-                <div className={classes.valueField}>
-                    <Field name="value"
-                        disabled={submitting}
-                        component={TextField}
-                        validate={TAG_VALUE_VALIDATION}
-                        label="Value" />
-                </div>
-                <div className={classes.buttonWrapper}>
-                    <Button type="submit" className={classes.saveButton}
-                        color="primary"
-                        size='small'
-                        disabled={invalid || submitting || pristine}
-                        variant="contained">
-                        ADD
-                    </Button>
-                    {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                </div>
-            </form>
-        );
+})(Form);
diff --git a/src/views-components/resource-properties-form/property-field-common.tsx b/src/views-components/resource-properties-form/property-field-common.tsx
new file mode 100644 (file)
index 0000000..028c46b
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// 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 { RootState } from '~/store/store';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selctors';
+
+export interface VocabularyProp {
+    vocabulary: Vocabulary;
+}
+
+export const mapStateToProps = (state: RootState): VocabularyProp => ({
+    vocabulary: getVocabulary(state.properties),
+});
+
+export const connectVocabulary = connect(mapStateToProps);
+
+export const ITEMS_PLACEHOLDER: string[] = [];
+
+export const hasError = ({ touched, invalid }: WrappedFieldMetaProps) =>
+    touched && invalid;
+
+export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
+    hasError(meta)
+        ? meta.error
+        : '';
+
+export const handleBlur = ({ onBlur, value }: WrappedFieldInputProps) =>
+    () =>
+        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),
+});
diff --git a/src/views-components/resource-properties-form/property-key-field.tsx b/src/views-components/resource-properties-form/property-key-field.tsx
new file mode 100644 (file)
index 0000000..e6708a3
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WrappedFieldProps, Field } 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 { TAG_KEY_VALIDATION } from '~/validators/validators';
+
+export const PROPERTY_KEY_FIELD_NAME = 'key';
+
+export const PropertyKeyField = connectVocabulary(
+    ({ vocabulary }: VocabularyProp) =>
+        <Field
+            name={PROPERTY_KEY_FIELD_NAME}
+            component={PropertyKeyInput}
+            vocabulary={vocabulary}
+            validate={getValidation(vocabulary)} />);
+
+const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
+    <Autocomplete
+        label='Key'
+        suggestions={getSuggestions(props.input.value, vocabulary)}
+        {...buildProps(props)}
+    />;
+
+const getValidation = memoize(
+    (vocabulary: Vocabulary) =>
+        vocabulary.strict
+            ? [...TAG_KEY_VALIDATION, matchTags(vocabulary)]
+            : TAG_KEY_VALIDATION);
+
+const matchTags = (vocabulary: Vocabulary) =>
+    (value: string) =>
+        getTagsList(vocabulary).find(tag => tag.includes(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);
diff --git a/src/views-components/resource-properties-form/property-value-field.tsx b/src/views-components/resource-properties-form/property-value-field.tsx
new file mode 100644 (file)
index 0000000..db2db3f
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WrappedFieldProps, Field, formValues } 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 { TAG_VALUE_VALIDATION } from '~/validators/validators';
+
+interface PropertyKeyProp {
+    propertyKey: string;
+}
+
+type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp;
+
+export const PROPERTY_VALUE_FIELD_NAME = 'value';
+
+export const PropertyValueField = compose(
+    connectVocabulary,
+    formValues({ propertyKey: PROPERTY_KEY_FIELD_NAME })
+)(
+    (props: PropertyValueFieldProps) =>
+        <Field
+            name={PROPERTY_VALUE_FIELD_NAME}
+            component={PropertyValueInput}
+            validate={getValidation(props)}
+            {...props} />);
+
+const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
+    <Autocomplete
+        label='Value'
+        suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
+        {...buildProps(props)}
+    />;
+
+const getValidation = (props: PropertyValueFieldProps) =>
+    isStrictTag(props.propertyKey, props.vocabulary)
+        ? [...TAG_VALUE_VALIDATION, matchTagValues(props)]
+        : TAG_VALUE_VALIDATION;
+
+const matchTagValues = ({ vocabulary, propertyKey }: PropertyValueFieldProps) =>
+    (value: string) =>
+        getTagValues(propertyKey, vocabulary).find(v => v.includes(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 : [];
+};
diff --git a/src/views-components/resource-properties-form/resource-properties-form.tsx b/src/views-components/resource-properties-form/resource-properties-form.tsx
new file mode 100644 (file)
index 0000000..a62b3d1
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { 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 { ProgressButton } from '~/components/progress-button/progress-button';
+import { GridClassKey } from '@material-ui/core/Grid';
+
+export interface ResourcePropertiesFormData {
+    [PROPERTY_KEY_FIELD_NAME]: string;
+    [PROPERTY_VALUE_FIELD_NAME]: string;
+}
+
+export type ResourcePropertiesFormProps = InjectedFormProps<ResourcePropertiesFormData> & WithStyles<GridClassKey>;
+
+export const ResourcePropertiesForm = ({ handleSubmit, submitting, invalid, classes }: ResourcePropertiesFormProps ) =>
+    <form onSubmit={handleSubmit}>
+        <Grid container spacing={16} classes={classes}>
+            <Grid item xs>
+                <PropertyKeyField />
+            </Grid>
+            <Grid item xs>
+                <PropertyValueField />
+            </Grid>
+            <Grid item xs>
+                <Button
+                    disabled={invalid}
+                    loading={submitting}
+                    color='primary'
+                    variant='contained'
+                    type='submit'>
+                    Add
+                </Button>
+            </Grid>
+        </Grid>
+    </form>;
+
+const Button = withStyles(theme => ({
+    root: { marginTop: theme.spacing.unit }
+}))(ProgressButton);
index 9aa881286580eefd0302c0c12f3f926fdabef6d3..fd4f0880a2abf9007d0a1e8d0db94704d20d16e2 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as React from 'react';
-import { reduxForm, Field, reset } from 'redux-form';
-import { compose, Dispatch } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress, Grid, Typography } from '@material-ui/core';
-import { TagProperty } from '~/models/tag';
-import { TextField } from '~/components/text-field/text-field';
+import { reduxForm, reset } from 'redux-form';
 import { createCollectionTag, COLLECTION_TAG_FORM_NAME } from '~/store/collection-panel/collection-panel-action';
-import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '~/validators/validators';
+import { ResourcePropertiesForm, ResourcePropertiesFormData } from '~/views-components/resource-properties-form/resource-properties-form';
+import { withStyles } from '@material-ui/core';
 
-type CssRules = 'root' | 'keyField' | 'valueField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+const Form = withStyles(({ spacing }) => ({ container: { marginBottom: spacing.unit * 2 } }))(ResourcePropertiesForm);
 
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        width: '100%',
-        display: 'flex'
-    },
-    keyField: {
-        width: '25%',
-        marginRight: theme.spacing.unit * 3
-    },
-    valueField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    buttonWrapper: {
-        paddingTop: '14px',
-        position: 'relative',
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: -9,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
+export const CollectionTagForm = reduxForm<ResourcePropertiesFormData>({
+    form: COLLECTION_TAG_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch<any>(createCollectionTag(data));
+        dispatch(reset(COLLECTION_TAG_FORM_NAME));
     }
-});
-
-interface CollectionTagFormDataProps {
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface CollectionTagFormActionProps {
-    handleSubmit: any;
-}
-
-type CollectionTagFormProps = CollectionTagFormDataProps & CollectionTagFormActionProps & WithStyles<CssRules>;
-
-export const CollectionTagForm = compose(
-    reduxForm({
-        form: COLLECTION_TAG_FORM_NAME,
-        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
-            dispatch<any>(createCollectionTag(data));
-            dispatch(reset(COLLECTION_TAG_FORM_NAME));
-        }
-    }),
-    withStyles(styles))(
-
-        class CollectionTagForm extends React.Component<CollectionTagFormProps> {
-
-            render() {
-                const { classes, submitting, pristine, invalid, handleSubmit } = this.props;
-                return (
-                    <form onSubmit={handleSubmit} className={classes.root}>
-                        <div className={classes.keyField}>
-                            <Field name="key"
-                                disabled={submitting}
-                                component={TextField}
-                                validate={TAG_KEY_VALIDATION}
-                                label="Key" />
-                        </div>
-                        <div className={classes.valueField}>
-                            <Field name="value"
-                                disabled={submitting}
-                                component={TextField}
-                                validate={TAG_VALUE_VALIDATION}
-                                label="Value" />
-                        </div>
-                        <div className={classes.buttonWrapper}>
-                            <Button type="submit" className={classes.saveButton}
-                                color="primary"
-                                size='small'
-                                disabled={invalid || submitting || pristine}
-                                variant="contained">
-                                ADD
-                            </Button>
-                            {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                        </div>
-                    </form>
-                );
-            }
-        }
-
-    );
+})(Form);
index 5da547423bb94155cf825645f52d057ec0c93449..6a214e9dd846aa0a6907bbefbdfd6cc47e7680e2 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { memoize } from 'lodash/fp';
 import { BooleanCommandInputParameter } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { Switch } from '@material-ui/core';
@@ -16,17 +17,23 @@ export const BooleanInput = ({ input }: BooleanInputProps) =>
         name={input.id}
         commandInput={input}
         component={BooleanInputComponent}
-        normalize={(value, prevValue) => !prevValue}
+        normalize={normalize}
     />;
 
+const normalize = (_: any, prevValue: boolean) => !prevValue;
+
 const BooleanInputComponent = (props: GenericInputProps) =>
     <GenericInput
         component={Input}
         {...props} />;
 
-const Input = (props: GenericInputProps) =>
+const Input = ({ input, commandInput }: GenericInputProps) =>
     <Switch
         color='primary'
-        checked={props.input.value}
-        onChange={() => props.input.onChange(props.input.value)} 
-        disabled={props.commandInput.disabled} />;
\ No newline at end of file
+        checked={input.value}
+        onChange={handleChange(input.onChange, input.value)}
+        disabled={commandInput.disabled} />;
+
+const handleChange = memoize(
+    (onChange: (value: string) => void, value: string) => () => onChange(value)
+);
index aa25fefc0bfbe9b45213ce220e8a855c8f22b68f..29ccd6e0ddf7d544855840fd2420fcba78e90135 100644 (file)
@@ -3,23 +3,24 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { memoize } from 'lodash/fp';
+import { Field } from 'redux-form';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
 import {
     isRequiredInput,
     DirectoryCommandInputParameter,
     CWLType,
     Directory
 } from '~/models/workflow';
-import { Field } from 'redux-form';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } 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';
 import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 import { TreeItem } from '~/components/tree/tree';
 import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
 import { CollectionResource } from '~/models/collection';
 import { ResourceKind } from '~/models/resource';
-import { ERROR_MESSAGE } from '../../../validators/require';
+import { ERROR_MESSAGE } from '~/validators/require';
 
 export interface DirectoryInputProps {
     input: DirectoryCommandInputParameter;
@@ -29,18 +30,25 @@ export const DirectoryInput = ({ input }: DirectoryInputProps) =>
         name={input.id}
         commandInput={input}
         component={DirectoryInputComponent}
-        format={(value?: Directory) => value ? value.basename : ''}
-        parse={(directory: CollectionResource): Directory => ({
-            class: CWLType.DIRECTORY,
-            location: `keep:${directory.portableDataHash}`,
-            basename: directory.name,
-        })}
-        validate={[
-            isRequiredInput(input)
-                ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
-                : () => undefined,
-        ]} />;
-
+        format={format}
+        parse={parse}
+        validate={getValidation(input)} />;
+
+const format = (value?: Directory) => value ? value.basename : '';
+
+const parse = (directory: CollectionResource): Directory => ({
+    class: CWLType.DIRECTORY,
+    location: `keep:${directory.portableDataHash}`,
+    basename: directory.name,
+});
+
+const getValidation = memoize(
+    (input: DirectoryCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
+            : () => undefined,
+    ])
+);
 
 interface DirectoryInputComponentState {
     open: boolean;
@@ -78,7 +86,7 @@ const DirectoryInputComponent = connect()(
             this.props.input.onChange(this.state.directory);
         }
 
-        setDirectory = (event: React.MouseEvent<HTMLElement>, { data }: TreeItem<ProjectsTreePickerItem>, pickerId: string) => {
+        setDirectory = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
             if ('kind' in data && data.kind === ResourceKind.COLLECTION) {
                 this.setState({ directory: data });
             } else {
index 86ff6fb14c2e733b21ed792e5e2ef8fb0dd9f64d..3b0289e79f4a8fee08ecde6546f99f7cf853b5d0 100644 (file)
@@ -3,9 +3,9 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { Select, MenuItem } from '@material-ui/core';
+import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow';
 import { GenericInputProps, GenericInput } from './generic-input';
 
 export interface EnumInputProps {
@@ -30,8 +30,20 @@ const Input = (props: GenericInputProps) => {
         onChange={props.input.onChange}
         disabled={props.commandInput.disabled} >
         {type.symbols.map(symbol =>
-            <MenuItem key={symbol} value={symbol.split('/').pop()}>
-                {symbol.split('/').pop()}
+            <MenuItem key={symbol} value={extractValue(symbol)}>
+                {extractValue(symbol)}
             </MenuItem>)}
     </Select>;
-};
\ No newline at end of file
+};
+
+/**
+ * Values in workflow definition have an absolute form, for example: 
+ * 
+ * ```#input_collector.cwl/enum_type/Pathway table```
+ * 
+ * We want a value that is in form accepted by backend.
+ * According to the example above, the correct value is:
+ * 
+ * ```Pathway table```
+ */
+const extractValue = (symbol: string) => symbol.split('/').pop();
index 7e0925e8e9e175481887c1c9988be0eee329f82e..0611100722a2e30512a675dc770b933d34cd88b6 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { memoize } from 'lodash/fp';
 import {
     isRequiredInput,
     FileCommandInputParameter,
@@ -28,18 +29,24 @@ export const FileInput = ({ input }: FileInputProps) =>
         name={input.id}
         commandInput={input}
         component={FileInputComponent}
-        format={(value?: File) => value ? value.basename : ''}
-        parse={(file: CollectionFile): File => ({
-            class: CWLType.FILE,
-            location: `keep:${file.id}`,
-            basename: file.name,
-        })}
-        validate={[
-            isRequiredInput(input)
-                ? (file?: File) => file ? undefined : ERROR_MESSAGE
-                : () => undefined,
-        ]} />;
+        format={format}
+        parse={parse}
+        validate={getValidation(input)} />;
 
+const format = (value?: File) => value ? value.basename : '';
+
+const parse = (file: CollectionFile): File => ({
+    class: CWLType.FILE,
+    location: `keep:${file.id}`,
+    basename: file.name,
+});
+
+const getValidation = memoize(
+    (input: FileCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? (file?: File) => file ? undefined : ERROR_MESSAGE
+            : () => undefined,
+    ]));
 
 interface FileInputComponentState {
     open: boolean;
@@ -77,7 +84,7 @@ const FileInputComponent = connect()(
             this.props.input.onChange(this.state.file);
         }
 
-        setFile = (event: React.MouseEvent<HTMLElement>, { data }: TreeItem<ProjectsTreePickerItem>, pickerId: string) => {
+        setFile = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
             if ('type' in data && data.type === CollectionFileType.FILE) {
                 this.setState({ file: data });
             } else {
index 56a58012b260bc2b6492671bc760d12f0479e4eb..a5905dc586d16bb684bfa3b9c443062c5a4e0ebc 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { memoize } from 'lodash/fp';
 import { FloatCommandInputParameter, isRequiredInput } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { isNumber } from '~/validators/is-number';
@@ -17,11 +18,17 @@ export const FloatInput = ({ input }: FloatInputProps) =>
         commandInput={input}
         component={Input}
         parse={parseFloat}
-        format={value => isNaN(value) ? '' : JSON.stringify(value)}
-        validate={[
-            isRequiredInput(input)
-                ? isNumber
-                : () => undefined,]} />;
+        format={format}
+        validate={getValidation(input)} />;
+
+const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+    (input: FloatCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? isNumber
+            : () => undefined,])
+);
 
 const Input = (props: GenericInputProps) =>
     <GenericInput
index 413ee49c7abc0eee00961fd66f941e2b71ef19ad..32ebeb75c27bc8ee6be8ed186b5051a5dcff0ab0 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { memoize } from 'lodash/fp';
 import { IntCommandInputParameter, isRequiredInput } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { isInteger } from '~/validators/is-integer';
@@ -17,13 +18,20 @@ export const IntInput = ({ input }: IntInputProps) =>
         name={input.id}
         commandInput={input}
         component={InputComponent}
-        parse={value => parseInt(value, 10)}
-        format={value => isNaN(value) ? '' : JSON.stringify(value)}
-        validate={[
-            isRequiredInput(input)
-                ? isInteger
-                : () => undefined,
-        ]} />;
+        parse={parse}
+        format={format}
+        validate={getValidation(input)} />;
+
+const parse = (value: any) => parseInt(value, 10);
+
+const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+    (input: IntCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? isInteger
+            : () => undefined,
+    ]));
 
 const InputComponent = (props: GenericInputProps) =>
     <GenericInput
index f6b50a7c47dd5bb417e75e503520f0be6ee691d9..7fc74315e98d6f3739381c10da74c3ea300438eb 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { memoize } from 'lodash/fp';
 import { isRequiredInput, StringCommandInputParameter } from '~/models/workflow';
 import { Field } from 'redux-form';
 import { require } from '~/validators/require';
@@ -17,11 +18,14 @@ export const StringInput = ({ input }: StringInputProps) =>
         name={input.id}
         commandInput={input}
         component={StringInputComponent}
-        validate={[
-            isRequiredInput(input)
-                ? require
-                : () => undefined,
-        ]} />;
+        validate={getValidation(input)} />;
+
+const getValidation = memoize(
+    (input: StringCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? require
+            : () => undefined,
+    ]));
 
 const StringInputComponent = (props: GenericInputProps) =>
     <GenericInput