Currently this configuration schema is supported:
```
{
- "API_HOST": "string"
+ "API_HOST": "string",
+ "VOCABULARY_URL": "string"
}
```
"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/",
version: string;
websocketUrl: string;
workbenchUrl: string;
+ vocabularyUrl: string;
}
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,
+ })));
};
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`;
// 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';
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;
render() {
return (
<RootRef rootRef={this.containerRef}>
- <FormControl fullWidth>
+ <FormControl fullWidth error={this.props.error}>
{this.renderLabel()}
{this.renderInput()}
+ {this.renderHelperText()}
{this.renderSuggestions()}
</FormControl>
</RootRef>
/>;
}
+ 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(
--- /dev/null
+// 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;
+ }
+};
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()}]`);
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} />;
});
};
+// force build comment #1
lastName: string;
uuid: string;
ownerUuid: string;
+ isAdmin: boolean;
}
export const getUserFullname = (user?: User) => {
--- /dev/null
+// 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
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;
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;
}
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() {
localStorage.removeItem(USER_LAST_NAME_KEY);
localStorage.removeItem(USER_UUID_KEY);
localStorage.removeItem(USER_OWNER_UUID_KEY);
+ localStorage.removeItem(USER_IS_ADMIN);
}
public login() {
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 => {
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>;
const favoriteService = new FavoriteService(linkService, groupsService);
const tagService = new TagService(linkService);
const searchService = new SearchService();
+ const vocabularyService = new VocabularyService(config.vocabularyUrl);
return {
ancestorsService,
virtualMachineService,
webdavClient,
workflowService,
+ vocabularyService,
};
};
--- /dev/null
+// 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);
+ }
+}
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';
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());
firstName: "John",
lastName: "Doe",
uuid: "uuid",
- ownerUuid: "ownerUuid"
+ ownerUuid: "ownerUuid",
+ isAdmin: true
}
});
});
firstName: "John",
lastName: "Doe",
uuid: "uuid",
- ownerUuid: "ownerUuid"
+ ownerUuid: "ownerUuid",
+ isAdmin: false
};
const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
expect(state).toEqual({
firstName: "John",
lastName: "Doe",
uuid: "uuid",
- ownerUuid: "ownerUuid"
+ ownerUuid: "ownerUuid",
+ isAdmin: false
};
const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
+ isAdmin: false
}
});
});
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
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));
+ }
}
};
--- /dev/null
+// 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,
+ }));
+};
--- /dev/null
+// 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;
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",
//
// 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);
--- /dev/null
+// 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),
+});
--- /dev/null
+// 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);
--- /dev/null
+// 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 : [];
+};
--- /dev/null
+// 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);
//
// 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);
// 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';
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)
+);
// 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;
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;
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 {
// 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 {
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();
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import {
isRequiredInput,
FileCommandInputParameter,
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;
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 {
// 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';
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
// 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';
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
// 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';
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