Merge branch 'master' into 14393-vocabulary
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Thu, 29 Nov 2018 09:42:35 +0000 (10:42 +0100)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Thu, 29 Nov 2018 09:42:35 +0000 (10:42 +0100)
refs #14393

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

17 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/vocabulary.ts [new file with mode: 0644]
src/services/services.ts
src/services/vocabulary-service/vocabulary-service.ts [new file with mode: 0644]
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/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

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 89458046ae9707d97571397fdfb7e37a54f3d9d4..64a24dcafcef48a5e245f487d214371c209bd636 100644 (file)
@@ -47,7 +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-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 ef6588575b299e1a36a96141b3a560b258cf848e..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} />;
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 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);
+    }
+}
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 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);