From: Daniel Kos Date: Fri, 30 Nov 2018 07:22:14 +0000 (+0100) Subject: Merge branch 'origin/master' into 14280-query-language X-Git-Tag: 1.4.0~95^2~3 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/519b431a41a9ab4815a1e4180ee78395b74a85de?hp=7e323d0c3e6734e6ca698c6bffcb9c24ef5acd13 Merge branch 'origin/master' into 14280-query-language # Conflicts: # package.json Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- diff --git a/README.md b/README.md index 998d4246..ea9bc02f 100644 --- 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" } ``` diff --git a/package.json b/package.json index 90fca4f2..1c066db8 100644 --- a/package.json +++ b/package.json @@ -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/", diff --git a/src/common/config.ts b/src/common/config.ts index 1ab73294..c74277e4 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -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(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 => ({ 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`; diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx index 85704c35..7da4ba4a 100644 --- a/src/components/autocomplete/autocomplete.tsx +++ b/src/components/autocomplete/autocomplete.tsx @@ -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 { value: string; items: Item[]; suggestions?: Suggestion[]; + error?: boolean; + helperText?: string; onChange: (event: React.ChangeEvent) => void; onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; @@ -38,9 +40,10 @@ export class Autocomplete extends React.Component - + {this.renderLabel()} {this.renderInput()} + {this.renderHelperText()} {this.renderSuggestions()} @@ -64,12 +67,16 @@ export class Autocomplete extends React.Component; } + renderHelperText(){ + return {this.props.helperText}; + } + renderSuggestions() { const { suggestions = [] } = this.props; return ( 0} - anchorEl={this.containerRef.current}> + anchorEl={this.inputRef.current}> {suggestions.map( diff --git a/src/components/progress-button/progress-button.tsx b/src/components/progress-button/progress-button.tsx new file mode 100644 index 00000000..14286dd2 --- /dev/null +++ b/src/components/progress-button/progress-button.tsx @@ -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) => + ; + +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; + } +}; diff --git a/src/index.tsx b/src/index.tsx index 88fd2298..801a56a1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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) => ; const MainPanelComponent = (props: any) => ; @@ -304,3 +306,4 @@ const createSampleProcess = ({ containerRequestService }: ServiceRepository) => }); }; +// force build comment #1 diff --git a/src/models/user.ts b/src/models/user.ts index c2f21e58..9f9c5347 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -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 index 00000000..ea23ad2c --- /dev/null +++ b/src/models/vocabulary.ts @@ -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; +} + +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 diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 50760bb4..edc6e24f 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -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 => { diff --git a/src/services/services.ts b/src/services/services.ts index f1ef86b8..b24b1d99 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -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; @@ -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 index 00000000..57bdd7c9 --- /dev/null +++ b/src/services/vocabulary-service/vocabulary-service.ts @@ -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(this.url) + .then(response => response.data); + } +} diff --git a/src/store/auth/auth-actions.test.ts b/src/store/auth/auth-actions.test.ts index 231c37b4..c54438b1 100644 --- a/src/store/auth/auth-actions.test.ts +++ b/src/store/auth/auth-actions.test.ts @@ -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 } }); }); diff --git a/src/store/auth/auth-reducer.test.ts b/src/store/auth/auth-reducer.test.ts index 8cde3245..a4017db3 100644 --- a/src/store/auth/auth-reducer.test.ts +++ b/src/store/auth/auth-reducer.test.ts @@ -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 } }); }); diff --git a/src/store/search-results-panel/search-results-panel-actions.ts b/src/store/search-results-panel/search-results-panel-actions.ts index 05da5b3e..f7dc5d45 100644 --- a/src/store/search-results-panel/search-results-panel-actions.ts +++ b/src/store/search-results-panel/search-results-panel-actions.ts @@ -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 diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index 0e3c76b2..37de6f8c 100644 --- a/src/store/sharing-dialog/sharing-dialog-actions.ts +++ b/src/store/sharing-dialog/sharing-dialog-actions.ts @@ -56,13 +56,18 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => { const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); - if (dialog) { dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME)); - const { items } = await permissionService.listResourcePermissions(dialog.data); - dispatch(initializePublicAccessForm(items)); - await dispatch(initializeManagementForm(items)); - dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); + try { + const { items } = await permissionService.listResourcePermissions(dialog.data); + dispatch(initializePublicAccessForm(items)); + await dispatch(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 index 00000000..799cffa0 --- /dev/null +++ b/src/store/vocabulary/vocabulary-actions.ts @@ -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 index 00000000..d317cb47 --- /dev/null +++ b/src/store/vocabulary/vocabulary-selctors.ts @@ -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_PROPERTY_NAME)(state) || DEFAULT_VOCABULARY; diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts index 5e1182bb..8c81e3bd 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.ts @@ -60,13 +60,13 @@ export const projectActionSet: ContextMenuActionSet = [[ dispatch(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", diff --git a/src/views-components/project-properties-dialog/project-properties-form.tsx b/src/views-components/project-properties-dialog/project-properties-form.tsx index 82ae0406..90c8c080 100644 --- a/src/views-components/project-properties-dialog/project-properties-form.tsx +++ b/src/views-components/project-properties-dialog/project-properties-form.tsx @@ -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 = (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({ + form: PROJECT_PROPERTIES_FORM_NAME, + onSubmit: (data, dispatch) => { + dispatch(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; - -export const ProjectPropertiesForm = compose( - reduxForm({ - form: PROJECT_PROPERTIES_FORM_NAME, - onSubmit: (data: TagProperty, dispatch: Dispatch) => { - dispatch(createProjectProperty(data)); - dispatch(reset(PROJECT_PROPERTIES_FORM_NAME)); - } - }), - withStyles(styles))( - ({ classes, submitting, pristine, invalid, handleSubmit }: ProjectPropertiesFormProps) => -
-
- -
-
- -
-
- - {submitting && } -
-
- ); +})(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 index 00000000..028c46b9 --- /dev/null +++ b/src/views-components/resource-properties-form/property-field-common.tsx @@ -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 index 00000000..e6708a39 --- /dev/null +++ b/src/views-components/resource-properties-form/property-key-field.tsx @@ -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) => + ); + +const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) => + ; + +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 index 00000000..db2db3f7 --- /dev/null +++ b/src/views-components/resource-properties-form/property-value-field.tsx @@ -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) => + ); + +const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) => + ; + +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 index 00000000..a62b3d15 --- /dev/null +++ b/src/views-components/resource-properties-form/resource-properties-form.tsx @@ -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 & WithStyles; + +export const ResourcePropertiesForm = ({ handleSubmit, submitting, invalid, classes }: ResourcePropertiesFormProps ) => +
+ + + + + + + + + + + +
; + +const Button = withStyles(theme => ({ + root: { marginTop: theme.spacing.unit } +}))(ProgressButton); diff --git a/src/views/collection-panel/collection-tag-form.tsx b/src/views/collection-panel/collection-tag-form.tsx index 9aa88128..fd4f0880 100644 --- a/src/views/collection-panel/collection-tag-form.tsx +++ b/src/views/collection-panel/collection-tag-form.tsx @@ -2,103 +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, 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 = (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({ + form: COLLECTION_TAG_FORM_NAME, + onSubmit: (data, dispatch) => { + dispatch(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; - -export const CollectionTagForm = compose( - reduxForm({ - form: COLLECTION_TAG_FORM_NAME, - onSubmit: (data: TagProperty, dispatch: Dispatch) => { - dispatch(createCollectionTag(data)); - dispatch(reset(COLLECTION_TAG_FORM_NAME)); - } - }), - withStyles(styles))( - - class CollectionTagForm extends React.Component { - - render() { - const { classes, submitting, pristine, invalid, handleSubmit } = this.props; - return ( -
-
- -
-
- -
-
- - {submitting && } -
-
- ); - } - } - - ); +})(Form); diff --git a/src/views/run-process-panel/inputs/boolean-input.tsx b/src/views/run-process-panel/inputs/boolean-input.tsx index 5da54742..6a214e9d 100644 --- a/src/views/run-process-panel/inputs/boolean-input.tsx +++ b/src/views/run-process-panel/inputs/boolean-input.tsx @@ -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) => ; -const Input = (props: GenericInputProps) => +const Input = ({ input, commandInput }: GenericInputProps) => 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) +); diff --git a/src/views/run-process-panel/inputs/directory-input.tsx b/src/views/run-process-panel/inputs/directory-input.tsx index aa25fefc..29ccd6e0 100644 --- a/src/views/run-process-panel/inputs/directory-input.tsx +++ b/src/views/run-process-panel/inputs/directory-input.tsx @@ -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, { data }: TreeItem, pickerId: string) => { + setDirectory = (_: {}, { data }: TreeItem) => { if ('kind' in data && data.kind === ResourceKind.COLLECTION) { this.setState({ directory: data }); } else { diff --git a/src/views/run-process-panel/inputs/enum-input.tsx b/src/views/run-process-panel/inputs/enum-input.tsx index 86ff6fb1..3b0289e7 100644 --- a/src/views/run-process-panel/inputs/enum-input.tsx +++ b/src/views/run-process-panel/inputs/enum-input.tsx @@ -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 => - - {symbol.split('/').pop()} + + {extractValue(symbol)} )} ; -}; \ 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(); diff --git a/src/views/run-process-panel/inputs/file-input.tsx b/src/views/run-process-panel/inputs/file-input.tsx index 7e0925e8..06111007 100644 --- a/src/views/run-process-panel/inputs/file-input.tsx +++ b/src/views/run-process-panel/inputs/file-input.tsx @@ -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, { data }: TreeItem, pickerId: string) => { + setFile = (_: {}, { data }: TreeItem) => { if ('type' in data && data.type === CollectionFileType.FILE) { this.setState({ file: data }); } else { diff --git a/src/views/run-process-panel/inputs/float-input.tsx b/src/views/run-process-panel/inputs/float-input.tsx index 56a58012..a5905dc5 100644 --- a/src/views/run-process-panel/inputs/float-input.tsx +++ b/src/views/run-process-panel/inputs/float-input.tsx @@ -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) => 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) => 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) =>