merge conflicts
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 23 Jul 2018 12:14:16 +0000 (14:14 +0200)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 23 Jul 2018 12:14:16 +0000 (14:14 +0200)
Feature #13781

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

12 files changed:
package.json
src/common/api/common-resource-service.ts
src/store/project/project-action.ts
src/store/project/project-reducer.ts
src/store/store.ts
src/validators/create-project/create-project-validator.tsx [new file with mode: 0644]
src/validators/max-length.tsx [new file with mode: 0644]
src/validators/require.tsx [new file with mode: 0644]
src/views-components/create-project-dialog/create-project-dialog.tsx
src/views-components/dialog-create/dialog-project-create.tsx
src/views/workbench/workbench.tsx
yarn.lock

index 0c06a6f17a357115da8c6897f0f159de7da51084..06fa893f97abe1dbcd18523df1deebf0d2660a3e 100644 (file)
@@ -6,6 +6,7 @@
     "@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": {
index 2541feab026989228c2cf14521c762c78be97d5c..3956fb7390983824a402456abc2144850b85cda2 100644 (file)
@@ -5,7 +5,7 @@
 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 {
@@ -26,6 +26,11 @@ export interface ListResults<T> {
     itemsAvailable: number;
 }
 
+export interface Errors {
+    errors: string[];
+    errorToken: string;
+}
+
 export class CommonResourceService<T extends Resource> {
 
     static mapResponseKeys = (response: any): Promise<any> =>
@@ -49,6 +54,12 @@ export class CommonResourceService<T extends Resource> {
             }
         }
 
+    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;
 
@@ -58,21 +69,21 @@ export class CommonResourceService<T extends Resource> {
     }
 
     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>> {
@@ -82,11 +93,11 @@ export class CommonResourceService<T extends Resource> {
             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) {
index 2a7a5c126860253af5929695d05ef9020978e53a..075e77d15483746a751d59553299546f01bd1460 100644 (file)
@@ -14,7 +14,6 @@ export const projectActions = unionize({
     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 }>(),
@@ -45,8 +44,7 @@ export const createProject = (project: Partial<ProjectResource>) =>
         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>;
index 40356c0c90123d7775484849d8fcf937fdbce87d..94a451a86574e70de24f6d143ede7217ce25cf9c 100644 (file)
@@ -18,6 +18,7 @@ interface ProjectCreator {
     opened: boolean;
     pending: boolean;
     ownerUuid: string;
+    error?: string;
 }
 
 export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
@@ -115,9 +116,8 @@ export const projectsReducer = (state: ProjectState = initialState, action: Proj
     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);
index adb7ddde133d8ac0b6c07e82d6d702017ed0bd10..01b06b9528a727cd3cb9642a16bffeb0e17954ea 100644 (file)
@@ -14,6 +14,7 @@ import { dataExplorerReducer, DataExplorerState } from './data-explorer/data-exp
 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' &&
@@ -37,7 +38,8 @@ const rootReducer = combineReducers({
     dataExplorer: dataExplorerReducer,
     sidePanel: sidePanelReducer,
     detailsPanel: detailsPanelReducer,
-    contextMenu: contextMenuReducer
+    contextMenu: contextMenuReducer,
+    form: formReducer
 });
 
 
diff --git a/src/validators/create-project/create-project-validator.tsx b/src/validators/create-project/create-project-validator.tsx
new file mode 100644 (file)
index 0000000..3eb636c
--- /dev/null
@@ -0,0 +1,9 @@
+// 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
diff --git a/src/validators/max-length.tsx b/src/validators/max-length.tsx
new file mode 100644 (file)
index 0000000..1f8e509
--- /dev/null
@@ -0,0 +1,24 @@
+// 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
diff --git a/src/validators/require.tsx b/src/validators/require.tsx
new file mode 100644 (file)
index 0000000..8ac3401
--- /dev/null
@@ -0,0 +1,16 @@
+// 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;
index 2f3e0b7fe319a349165da8b86affd683234ebbbf..43621bf73c0739edcc3d99bcf3077477e1b70d4f 100644 (file)
@@ -4,8 +4,10 @@
 
 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";
@@ -14,10 +16,10 @@ const mapStateToProps = (state: RootState) => ({
     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));
         });
@@ -28,8 +30,11 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
         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);
index aefb8159871677ce2de8fb8ba13f4442d3d7133d..34c655e2b9922ff9da8c338cac14e59785795c32 100644 (file)
@@ -3,16 +3,91 @@
 // 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: {
@@ -22,7 +97,9 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
         marginLeft: theme.spacing.unit,
         marginRight: "20px",
     },
-    dialogContent: {
+    formContainer: {
+        display: "flex",
+        flexDirection: "column",
         marginTop: "20px",
     },
     dialogTitle: {
@@ -34,109 +111,18 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     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);
index a62b713a52aeb7b4149e49248dc1ac4a7fe7fea1..b1e7cd78659efe4cfa88239adaa591d7cda4813b 100644 (file)
@@ -193,7 +193,7 @@ export const Workbench = withStyles(styles)(
                                     <Route path="/projects/:id" render={this.renderProjectPanel} />
                                 </Switch>
                             </div>
-                            { user && <DetailsPanel /> }
+                            {user && <DetailsPanel />}
                         </main>
                         <ContextMenu />
                         <CreateProjectDialog />
index 6960aaf60099961087c01579ef83f78ffd28bdd9..3557ebefe81e4c1e197f30077f9e8c6892ce28a9 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     "@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"
@@ -2487,6 +2494,10 @@ es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14:
     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"
@@ -3337,6 +3348,10 @@ hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0:
   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"
@@ -4601,7 +4616,7 @@ locate-path@^2.0.0:
     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"
 
@@ -6385,11 +6400,24 @@ redux-devtools@3.4.1:
     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: