"@material-ui/core": "1.4.0",
"@material-ui/icons": "1.1.0",
"@types/lodash": "4.14.112",
+ "@types/redux-form": "7.4.1",
"axios": "0.18.0",
"classnames": "2.2.6",
"lodash": "4.17.10",
"@types/react-router-dom": "4.2.7",
"@types/react-router-redux": "5.0.15",
"@types/redux-devtools": "3.0.44",
+ "@types/redux-form": "7.4.1",
"axios-mock-adapter": "1.15.0",
"enzyme": "3.3.0",
"enzyme-adapter-react-16": "1.1.1",
"jest-localstorage-mock": "2.2.0",
"redux-devtools": "3.4.1",
+ "redux-form": "7.4.2",
"typescript": "2.9.2"
},
"moduleNameMapper": {
import * as _ from "lodash";
import { FilterBuilder } from "./filter-builder";
import { OrderBuilder } from "./order-builder";
-import { AxiosInstance } from "axios";
+import { AxiosInstance, AxiosPromise } from "axios";
import { Resource } from "../../models/resource";
export interface ListArguments {
itemsAvailable: number;
}
+export interface Errors {
+ errors: string[];
+ errorToken: string;
+}
+
export class CommonResourceService<T extends Resource> {
static mapResponseKeys = (response: any): Promise<any> =>
}
}
+ static defaultResponse<R>(promise: AxiosPromise<R>): Promise<R> {
+ return promise
+ .then(CommonResourceService.mapResponseKeys)
+ .catch(({ response }) => Promise.reject<Errors>(CommonResourceService.mapResponseKeys(response)));
+ }
+
protected serverApi: AxiosInstance;
protected resourceType: string;
}
create(data: Partial<T>) {
- return this.serverApi
- .post<T>(this.resourceType, CommonResourceService.mapKeys(_.snakeCase)(data))
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .post<T>(this.resourceType, CommonResourceService.mapKeys(_.snakeCase)(data)));
}
delete(uuid: string): Promise<T> {
- return this.serverApi
- .delete(this.resourceType + uuid)
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .delete(this.resourceType + uuid));
}
get(uuid: string) {
- return this.serverApi
- .get<T>(this.resourceType + uuid)
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .get<T>(this.resourceType + uuid));
}
list(args: ListArguments = {}): Promise<ListResults<T>> {
filters: filters ? filters.serialize() : undefined,
order: order ? order.getOrder() : undefined
};
- return this.serverApi
- .get(this.resourceType, {
- params: CommonResourceService.mapKeys(_.snakeCase)(params)
- })
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .get(this.resourceType, {
+ params: CommonResourceService.mapKeys(_.snakeCase)(params)
+ }));
}
update(uuid: string) {
const userUuid = getState().auth.user!.uuid;
dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
const isFavorite = checkFavorite(resource.uuid, getState().favorites);
- const promise = isFavorite
+ const promise: (any) = isFavorite
? favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
: favoriteService.create({ userUuid, resource });
promise
- .then(fav => {
+ .then(() => {
dispatch(favoritesActions.UPDATE_FAVORITES({ [resource.uuid]: !isFavorite }));
});
};
CLOSE_PROJECT_CREATOR: ofType<{}>(),
CREATE_PROJECT: ofType<Partial<ProjectResource>>(),
CREATE_PROJECT_SUCCESS: ofType<ProjectResource>(),
- CREATE_PROJECT_ERROR: ofType<string>(),
REMOVE_PROJECT: ofType<string>(),
PROJECTS_REQUEST: ofType<string>(),
PROJECTS_SUCCESS: ofType<{ projects: ProjectResource[], parentItemId?: string }>(),
dispatch(projectActions.CREATE_PROJECT(projectData));
return projectService
.create(projectData)
- .then(project => dispatch(projectActions.CREATE_PROJECT_SUCCESS(project)))
- .catch(() => dispatch(projectActions.CREATE_PROJECT_ERROR("Could not create a project")));
+ .then(project => dispatch(projectActions.CREATE_PROJECT_SUCCESS(project)));
};
export type ProjectAction = UnionOf<typeof projectActions>;
opened: boolean;
pending: boolean;
ownerUuid: string;
+ error?: string;
}
export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
return projectActions.match(action, {
OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true, pending: false }),
CLOSE_PROJECT_CREATOR: () => updateCreator(state, { opened: false }),
- CREATE_PROJECT: () => updateCreator(state, { opened: false, pending: true }),
- CREATE_PROJECT_SUCCESS: () => updateCreator(state, { ownerUuid: "", pending: false }),
- CREATE_PROJECT_ERROR: () => updateCreator(state, { ownerUuid: "", pending: false }),
+ CREATE_PROJECT: () => updateCreator(state, { error: undefined }),
+ CREATE_PROJECT_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
REMOVE_PROJECT: () => state,
PROJECTS_REQUEST: itemId => {
const items = _.cloneDeep(state.items);
import { projectPanelMiddleware } from './project-panel/project-panel-middleware';
import { detailsPanelReducer, DetailsPanelState } from './details-panel/details-panel-reducer';
import { contextMenuReducer, ContextMenuState } from './context-menu/context-menu-reducer';
+import { reducer as formReducer } from 'redux-form';
import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
const composeEnhancers =
sidePanel: sidePanelReducer,
detailsPanel: detailsPanelReducer,
contextMenu: contextMenuReducer,
+ form: formReducer,
favorites: favoritesReducer,
});
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
-
-type CssRules = "formInputError";
-
-const styles: StyleRulesCallback<CssRules> = theme => ({
- formInputError: {
- color: "#ff0000",
- marginLeft: "5px",
- fontSize: "11px",
- }
-});
-
-type ValidatorProps = {
- value: string,
- onChange: (isValid: boolean | string) => void;
- render: (hasError: boolean) => React.ReactElement<any>;
- isRequired: boolean;
-};
-
-interface ValidatorState {
- isPatternValid: boolean;
- isLengthValid: boolean;
-}
-
-const nameRegEx = /^[a-zA-Z0-9-_ ]+$/;
-const maxInputLength = 60;
-
-export const Validator = withStyles(styles)(
- class extends React.Component<ValidatorProps & WithStyles<CssRules>> {
- state: ValidatorState = {
- isPatternValid: true,
- isLengthValid: true
- };
-
- componentWillReceiveProps(nextProps: ValidatorProps) {
- const { value } = nextProps;
-
- if (this.props.value !== value) {
- this.setState({
- isPatternValid: value.match(nameRegEx),
- isLengthValid: value.length < maxInputLength
- }, () => this.onChange());
- }
- }
-
- onChange() {
- const { value, onChange, isRequired } = this.props;
- const { isPatternValid, isLengthValid } = this.state;
- const isValid = value && isPatternValid && isLengthValid && (isRequired || (!isRequired && value.length > 0));
-
- onChange(isValid);
- }
-
- render() {
- const { classes, isRequired, value } = this.props;
- const { isPatternValid, isLengthValid } = this.state;
-
- return (
- <span>
- {this.props.render(!(isPatternValid && isLengthValid) && (isRequired || (!isRequired && value.length > 0)))}
- {!isPatternValid && (isRequired || (!isRequired && value.length > 0)) ?
- <span className={classes.formInputError}>This field allow only alphanumeric characters, dashes, spaces and underscores.<br/></span> : null}
- {!isLengthValid ?
- <span className={classes.formInputError}>This field should have max 60 characters.</span> : null}
- </span>
- );
- }
- }
-);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { require } from '../require';
+import { maxLength } from '../max-length';
+
+export const PROJECT_NAME_VALIDATION = [require, maxLength(255)];
+export const PROJECT_DESCRIPTION_VALIDATION = [maxLength(255)];
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = 'Maximum string length of this field is: ';
+export const DEFAULT_MAX_VALUE = 60;
+
+interface MaxLengthProps {
+ maxLengthValue: number;
+ defaultErrorMessage: string;
+}
+
+// TODO types for maxLength
+export const maxLength: any = (maxLengthValue = DEFAULT_MAX_VALUE, errorMessage = ERROR_MESSAGE) => {
+ return (value: string) => {
+ if (value) {
+ return value && value.length <= maxLengthValue ? undefined : `${errorMessage || ERROR_MESSAGE} ${maxLengthValue}`;
+ }
+
+ return undefined;
+ };
+};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = 'This field is required.';
+
+interface RequiredProps {
+ value: string;
+}
+
+// TODO types for require
+export const require: any = (value: string) => {
+ return value && value.length > 0 ? undefined : ERROR_MESSAGE;
+};
import { connect } from "react-redux";
import { Dispatch } from "redux";
+import { SubmissionError } from "redux-form";
+
import { RootState } from "../../store/store";
-import { DialogProjectCreate as DialogProjectCreateComponent } from "../dialog-create/dialog-project-create";
+import { DialogProjectCreate } from "../dialog-create/dialog-project-create";
import { projectActions, createProject, getProjectList } from "../../store/project/project-action";
import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
open: state.projects.creator.opened
});
-const submit = (data: { name: string, description: string }) =>
+const addProject = (data: { name: string, description: string }) =>
(dispatch: Dispatch, getState: () => RootState) => {
const { ownerUuid } = getState().projects.creator;
- dispatch<any>(createProject(data)).then(() => {
+ return dispatch<any>(createProject(data)).then(() => {
dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
dispatch<any>(getProjectList(ownerUuid));
});
dispatch(projectActions.CLOSE_PROJECT_CREATOR());
},
onSubmit: (data: { name: string, description: string }) => {
- dispatch<any>(submit(data));
+ return dispatch<any>(addProject(data))
+ .catch((e: any) => {
+ throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Project with this name already exists." : "" });
+ });
}
});
-export const CreateProjectDialog = connect(mapStateToProps, mapDispatchToProps)(DialogProjectCreateComponent);
+export const CreateProjectDialog = connect(mapStateToProps, mapDispatchToProps)(DialogProjectCreate);
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { reduxForm, Field } from 'redux-form';
+import { compose } from 'redux';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
-import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
-import { Validator } from '../../utils/dialog-validator';
+import { PROJECT_NAME_VALIDATION, PROJECT_DESCRIPTION_VALIDATION } from '../../validators/create-project/create-project-validator';
-type CssRules = "button" | "lastButton" | "dialogContent" | "textField" | "dialog" | "dialogTitle";
+type CssRules = "button" | "lastButton" | "formContainer" | "textField" | "dialog" | "dialogTitle" | "createProgress" | "dialogActions";
const styles: StyleRulesCallback<CssRules> = theme => ({
button: {
marginLeft: theme.spacing.unit,
marginRight: "20px",
},
- dialogContent: {
+ formContainer: {
+ display: "flex",
+ flexDirection: "column",
marginTop: "20px",
},
dialogTitle: {
dialog: {
minWidth: "600px",
minHeight: "320px"
+ },
+ createProgress: {
+ position: "absolute",
+ minWidth: "20px",
+ right: "95px"
+ },
+ dialogActions: {
+ marginBottom: "24px"
}
});
-
-interface ProjectCreateProps {
+interface DialogProjectProps {
open: boolean;
handleClose: () => void;
onSubmit: (data: { name: string, description: string }) => void;
+ handleSubmit: any;
+ submitting: boolean;
}
-interface DialogState {
- name: string;
- description: string;
- isNameValid: boolean;
- isDescriptionValid: boolean;
+interface TextFieldProps {
+ label: string;
+ floatinglabeltext: string;
+ className?: string;
+ input?: string;
+ meta?: any;
}
-export const DialogProjectCreate = withStyles(styles)(
- class extends React.Component<ProjectCreateProps & WithStyles<CssRules>> {
- state: DialogState = {
- name: '',
- description: '',
- isNameValid: false,
- isDescriptionValid: true
- };
-
+export const DialogProjectCreate = compose(
+ reduxForm({ form: 'projectCreateDialog' }),
+ withStyles(styles))(
+ class DialogProjectCreate extends React.Component<DialogProjectProps & WithStyles<CssRules>> {
render() {
- const { name, description } = this.state;
- const { classes, open, handleClose } = this.props;
+ const { classes, open, handleClose, handleSubmit, onSubmit, submitting } = this.props;
return (
<Dialog
open={open}
- onClose={handleClose}>
+ onClose={handleClose}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}>
<div className={classes.dialog}>
- <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a project</DialogTitle>
- <DialogContent className={classes.dialogContent}>
- <Validator
- value={name}
- onChange={e => this.isNameValid(e)}
- isRequired={true}
- render={hasError =>
- <TextField
- margin="dense"
- className={classes.textField}
- id="name"
- onChange={e => this.handleProjectName(e)}
- label="Project name"
- error={hasError}
- fullWidth/>}/>
- <Validator
- value={description}
- onChange={e => this.isDescriptionValid(e)}
- isRequired={false}
- render={hasError =>
- <TextField
- margin="dense"
- className={classes.textField}
- id="description"
- onChange={e => this.handleDescriptionValue(e)}
- label="Description - optional"
- error={hasError}
- fullWidth/>}/>
- </DialogContent>
- <DialogActions>
- <Button onClick={handleClose} className={classes.button} color="primary">CANCEL</Button>
- <Button onClick={this.handleSubmit} className={classes.lastButton} color="primary"
- disabled={!this.state.isNameValid || (!this.state.isDescriptionValid && description.length > 0)}
- variant="raised">CREATE A PROJECT</Button>
- </DialogActions>
+ <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
+ <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a
+ project</DialogTitle>
+ <DialogContent className={classes.formContainer}>
+ <Field name="name"
+ component={this.renderTextField}
+ floatinglabeltext="Project Name"
+ validate={PROJECT_NAME_VALIDATION}
+ className={classes.textField}
+ label="Project Name"/>
+ <Field name="description"
+ component={this.renderTextField}
+ floatinglabeltext="Description - optional"
+ validate={PROJECT_DESCRIPTION_VALIDATION}
+ className={classes.textField}
+ label="Description - optional"/>
+ </DialogContent>
+ <DialogActions className={classes.dialogActions}>
+ <Button onClick={handleClose} className={classes.button} color="primary"
+ disabled={submitting}>CANCEL</Button>
+ <Button type="submit"
+ className={classes.lastButton}
+ color="primary"
+ disabled={submitting}
+ variant="contained">
+ CREATE A PROJECT
+ </Button>
+ {submitting && <CircularProgress size={20} className={classes.createProgress}/>}
+ </DialogActions>
+ </form>
</div>
</Dialog>
);
}
- handleSubmit = () => {
- this.props.onSubmit({
- name: this.state.name,
- description: this.state.description
- });
- }
-
- handleProjectName(e: React.ChangeEvent<HTMLInputElement>) {
- this.setState({
- name: e.target.value,
- });
- }
-
- handleDescriptionValue(e: React.ChangeEvent<HTMLInputElement>) {
- this.setState({
- description: e.target.value,
- });
- }
-
- isNameValid(value: boolean | string) {
- this.setState({
- isNameValid: value,
- });
- }
-
- isDescriptionValid(value: boolean | string) {
- this.setState({
- isDescriptionValid: value,
- });
- }
+ renderTextField = ({ input, label, meta: { touched, error }, ...custom }: TextFieldProps) => (
+ <TextField
+ helperText={touched && error}
+ label={label}
+ className={this.props.classes.textField}
+ error={touched && !!error}
+ autoComplete='off'
+ {...input}
+ {...custom}
+ />
+ )
}
);
"@types/react" "*"
redux "^3.6.0"
+"@types/redux-form@^7.4.1":
+ version "7.4.1"
+ resolved "https://registry.yarnpkg.com/@types/redux-form/-/redux-form-7.4.1.tgz#df84bbda5f06e4d517210797c3cfdc573c3bda36"
+ dependencies:
+ "@types/react" "*"
+ redux "^3.6.0 || ^4.0.0"
+
abab@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
es6-symbol "~3.1.1"
next-tick "1"
+es6-error@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d"
+
es6-iterator@^2.0.1, es6-iterator@~2.0.1, es6-iterator@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
version "2.5.5"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
+hoist-non-react-statics@^2.5.4:
+ version "2.5.5"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
+
home-or-tmp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
p-locate "^2.0.0"
path-exists "^3.0.0"
-lodash-es@^4.17.5, lodash-es@^4.2.1:
+lodash-es@^4.17.10, lodash-es@^4.17.5, lodash-es@^4.2.1:
version "4.17.10"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.10.tgz#62cd7104cdf5dd87f235a837f0ede0e8e5117e05"
prop-types "^15.5.7"
redux-devtools-instrument "^1.0.1"
+redux-form@^7.4.2:
+ version "7.4.2"
+ resolved "https://registry.yarnpkg.com/redux-form/-/redux-form-7.4.2.tgz#d6061088fb682eb9fc5fb9749bd8b102f03154b0"
+ dependencies:
+ es6-error "^4.1.1"
+ hoist-non-react-statics "^2.5.4"
+ invariant "^2.2.4"
+ is-promise "^2.1.0"
+ lodash "^4.17.10"
+ lodash-es "^4.17.10"
+ prop-types "^15.6.1"
+ react-lifecycles-compat "^3.0.4"
+
redux-thunk@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"
-redux@4.0.0, "redux@>= 3.7.2", redux@^4.0.0:
+redux@4.0.0, "redux@>= 3.7.2", "redux@^3.6.0 || ^4.0.0", redux@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03"
dependencies: