"@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) {
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';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
dataExplorer: dataExplorerReducer,
sidePanel: sidePanelReducer,
detailsPanel: detailsPanelReducer,
- contextMenu: contextMenuReducer
+ contextMenu: contextMenuReducer,
+ form: formReducer
});
--- /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 NAME = [require, maxLength(255)];
+export const DESCRIPTION = [maxLength(255)];
\ No newline at end of file
--- /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
+const maxLength: any = (maxLengthValue = DEFAULT_MAX_VALUE, errorMessage = ERROR_MESSAGE) => {
+ return (value: string) => {
+ if (value) {
+ return value && value && value.length <= maxLengthValue ? undefined : `${errorMessage || ERROR_MESSAGE} ${maxLengthValue}`;
+ }
+
+ return undefined;
+ };
+};
+
+export default maxLength;
\ No newline at end of file
--- /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 RequireProps {
+ value: string;
+}
+
+// TODO types for require
+const require: any = (value: string, errorMessage = ERROR_MESSAGE) => {
+ return value && value.toString().length > 0 ? undefined : ERROR_MESSAGE;
+};
+
+export default require;
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 }) =>
+export 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 { NAME, DESCRIPTION } from '../../validators/create-project/create-project-validator';
-type CssRules = "button" | "lastButton" | "dialogContent" | "textField" | "dialog" | "dialogTitle";
+interface DialogProjectProps {
+ open: boolean;
+ handleClose: () => void;
+ onSubmit: (data: { name: string, description: string }) => void;
+ handleSubmit: any;
+ submitting: boolean;
+}
+
+interface TextFieldProps {
+ label: string;
+ floatinglabeltext: string;
+ className?: string;
+ input?: string;
+ meta?: any;
+}
+
+class DialogProjectCreate extends React.Component<DialogProjectProps & WithStyles<CssRules>> {
+ render() {
+ const { classes, open, handleClose, handleSubmit, onSubmit, submitting } = this.props;
+
+ return (
+ <Dialog
+ open={open}
+ onClose={handleClose}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}>
+ <div className={classes.dialog}>
+ <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={NAME}
+ className={classes.textField}
+ label="Project Name" />
+ <Field name="description"
+ component={this.renderTextField}
+ floatinglabeltext="Description - optional"
+ validate={DESCRIPTION}
+ 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>
+ );
+ }
+
+ 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}
+ />
+ )
+}
+
+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 {
- open: boolean;
- handleClose: () => void;
- onSubmit: (data: { name: string, description: string }) => void;
-}
-
-interface DialogState {
- name: string;
- description: string;
- isNameValid: boolean;
- isDescriptionValid: boolean;
-}
-
-export const DialogProjectCreate = withStyles(styles)(
- class extends React.Component<ProjectCreateProps & WithStyles<CssRules>> {
- state: DialogState = {
- name: '',
- description: '',
- isNameValid: false,
- isDescriptionValid: true
- };
-
- render() {
- const { name, description } = this.state;
- const { classes, open, handleClose } = this.props;
-
- return (
- <Dialog
- open={open}
- onClose={handleClose}>
- <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>
- </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,
- });
- }
- }
-);
+export default compose(
+ reduxForm({ form: 'projectCreateDialog' }),
+ withStyles(styles)
+)(DialogProjectCreate);
<Route path="/projects/:id" render={this.renderProjectPanel} />
</Switch>
</div>
- { user && <DetailsPanel /> }
+ {user && <DetailsPanel />}
</main>
<ContextMenu />
<CreateProjectDialog />
"@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: