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-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} />;
--- /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
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);
+ }
+}
--- /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;
//
// 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);