From 280421e304c554363b2e39d478837c092ac8fc03 Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Sun, 2 Sep 2018 11:22:16 -0400 Subject: [PATCH] Queue and batch dependency actions (#181) * update redux action structure - update dependencies reducer and tests - add queue reducer and tests - move snapshots to separate directory - add `queued-` statuses * update dependency saga for queued actions - update depedency saga tests - add loadProjectDependencies to read-from-disk.service * update UI to use new queue methods * fix flow typing * update UI to reflect new queue/batch actions - update tests to reflect structural changes - exclude `queue` state from redux-storage * add queue indicator to dependency list * resolve review comments --- src/actions/index.js | 156 ++++--- .../AddDependencySearchResult.js | 72 +-- .../DeleteDependencyButton.js | 44 +- .../DependencyDetailsTable.js | 2 +- .../DependencyInstalling.js | 14 +- .../DependencyManagementPane.js | 70 ++- .../DependencyUpdateRow.js | 21 +- src/reducers/dependencies.reducer.js | 89 ++-- src/reducers/dependencies.reducer.test.js | 308 ++++++++++--- src/reducers/index.js | 4 +- src/reducers/package-json-locked.reducer.js | 92 ---- src/reducers/projects.reducer.js | 12 +- src/reducers/projects.reducer.test.js | 30 +- src/reducers/queue.reducer.js | 136 ++++++ src/reducers/queue.reducer.test.js | 324 +++++++++++++ src/sagas/dependency.saga.js | 190 +++++--- src/sagas/dependency.saga.test.js | 428 ++++++++++++++---- src/services/dependencies.service.js | 45 +- src/services/read-from-disk.service.js | 79 ++-- src/store/index.js | 6 +- src/types.js | 15 +- 21 files changed, 1567 insertions(+), 570 deletions(-) delete mode 100644 src/reducers/package-json-locked.reducer.js create mode 100644 src/reducers/queue.reducer.js create mode 100644 src/reducers/queue.reducer.test.js diff --git a/src/actions/index.js b/src/actions/index.js index 01cd7ef5..e1a1d110 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -2,9 +2,14 @@ import uuid from 'uuid/v1'; import { loadAllProjectDependencies } from '../services/read-from-disk.service'; -import { getInternalProjectById } from '../reducers/projects.reducer'; -import type { Project, ProjectsMap, Task, Dependency } from '../types'; +import type { + Project, + ProjectsMap, + Task, + Dependency, + QueuedDependency, +} from '../types'; // // @@ -31,15 +36,18 @@ export const RECEIVE_DATA_FROM_TASK_EXECUTION = export const LAUNCH_DEV_SERVER = 'LAUNCH_DEV_SERVER'; export const CLEAR_CONSOLE = 'CLEAR_CONSOLE'; export const LOAD_DEPENDENCY_INFO_FROM_DISK = 'LOAD_DEPENDENCY_INFO_FROM_DISK'; -export const ADD_DEPENDENCY_START = 'ADD_DEPENDENCY_START'; -export const ADD_DEPENDENCY_ERROR = 'ADD_DEPENDENCY_ERROR'; -export const ADD_DEPENDENCY_FINISH = 'ADD_DEPENDENCY_FINISH'; -export const UPDATE_DEPENDENCY_START = 'UPDATE_DEPENDENCY_START'; -export const UPDATE_DEPENDENCY_ERROR = 'UPDATE_DEPENDENCY_ERROR'; -export const UPDATE_DEPENDENCY_FINISH = 'UPDATE_DEPENDENCY_FINISH'; -export const DELETE_DEPENDENCY_START = 'DELETE_DEPENDENCY_START'; -export const DELETE_DEPENDENCY_ERROR = 'DELETE_DEPENDENCY_ERROR'; -export const DELETE_DEPENDENCY_FINISH = 'DELETE_DEPENDENCY_FINISH'; +export const ADD_DEPENDENCY = 'ADD_DEPENDENCY'; +export const UPDATE_DEPENDENCY = 'UPDATE_DEPENDENCY'; +export const DELETE_DEPENDENCY = 'DELETE_DEPENDENCY'; +export const INSTALL_DEPENDENCIES_START = 'INSTALL_DEPENDENCIES_START'; +export const INSTALL_DEPENDENCIES_ERROR = 'INSTALL_DEPENDENCIES_ERROR'; +export const INSTALL_DEPENDENCIES_FINISH = 'INSTALL_DEPENDENCIES_FINISH'; +export const UNINSTALL_DEPENDENCIES_START = 'UNINSTALL_DEPENDENCIES_START'; +export const UNINSTALL_DEPENDENCIES_ERROR = 'UNINSTALL_DEPENDENCIES_ERROR'; +export const UNINSTALL_DEPENDENCIES_FINISH = 'UNINSTALL_DEPENDENCIES_FINISH'; +export const QUEUE_DEPENDENCY_INSTALL = 'QUEUE_DEPENDENCY_INSTALL'; +export const QUEUE_DEPENDENCY_UNINSTALL = 'QUEUE_DEPENDENCY_UNINSTALL'; +export const START_NEXT_ACTION_IN_QUEUE = 'START_NEXT_ACTION_IN_QUEUE'; export const SHOW_IMPORT_EXISTING_PROJECT_PROMPT = 'SHOW_IMPORT_EXISTING_PROJECT_PROMPT'; export const IMPORT_EXISTING_PROJECT_START = 'IMPORT_EXISTING_PROJECT_START'; @@ -87,20 +95,13 @@ export const loadDependencyInfoFromDisk = ( projectPath: string ) => { return (dispatch: any, getState: Function) => { - // The `project` this action receives is the "fit-for-consumption" one. - // We need the internal version, `ProjectInternal`, so that we can see the - // raw dependency information. - const internalProject = getInternalProjectById(getState(), projectId); - - loadAllProjectDependencies(internalProject, projectPath).then( - dependencies => { - dispatch({ - type: LOAD_DEPENDENCY_INFO_FROM_DISK, - projectId, - dependencies, - }); - } - ); + loadAllProjectDependencies(projectPath).then(dependencies => { + dispatch({ + type: LOAD_DEPENDENCY_INFO_FROM_DISK, + projectId, + dependencies, + }); + }); }; }; @@ -182,91 +183,118 @@ export const clearConsole = (task: Task) => ({ task, }); -export const deleteDependencyStart = ( +export const addDependency = (projectId: string, dependencyName: string) => ({ + type: ADD_DEPENDENCY, + projectId, + dependencyName, +}); + +export const updateDependency = ( projectId: string, - dependencyName: string + dependencyName: string, + latestVersion: string ) => ({ - type: DELETE_DEPENDENCY_START, + type: UPDATE_DEPENDENCY, projectId, dependencyName, + latestVersion, }); -export const deleteDependencyError = ( +export const deleteDependency = ( projectId: string, dependencyName: string ) => ({ - type: DELETE_DEPENDENCY_ERROR, + type: DELETE_DEPENDENCY, projectId, dependencyName, }); -export const deleteDependencyFinish = ( +export const installDependenciesStart = ( projectId: string, - dependencyName: string + dependencies: Array ) => ({ - type: DELETE_DEPENDENCY_FINISH, + type: INSTALL_DEPENDENCIES_START, projectId, - dependencyName, + dependencies, }); -export const updateDependencyStart = ( +export const installDependencyStart = ( projectId: string, - dependencyName: string, - latestVersion: string + name: string, + version: string, + updating?: boolean +) => installDependenciesStart(projectId, [{ name, version, updating }]); + +export const installDependenciesError = ( + projectId: string, + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_START, + type: INSTALL_DEPENDENCIES_ERROR, projectId, - dependencyName, - latestVersion, + dependencies, }); -export const updateDependencyError = ( +export const installDependenciesFinish = ( projectId: string, - dependencyName: string + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_ERROR, + type: INSTALL_DEPENDENCIES_FINISH, projectId, - dependencyName, + dependencies, }); -export const updateDependencyFinish = ( +export const uninstallDependenciesStart = ( projectId: string, - dependencyName: string, - latestVersion: string + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_FINISH, + type: UNINSTALL_DEPENDENCIES_START, projectId, - dependencyName, - latestVersion, + dependencies, }); -export const addDependencyStart = ( +export const uninstallDependencyStart = (projectId: string, name: string) => + uninstallDependenciesStart(projectId, [{ name }]); + +export const uninstallDependenciesError = ( projectId: string, - dependencyName: string, - version: string + dependencies: Array ) => ({ - type: ADD_DEPENDENCY_START, + type: UNINSTALL_DEPENDENCIES_ERROR, projectId, - dependencyName, - version, + dependencies, }); -export const addDependencyError = ( +export const uninstallDependenciesFinish = ( projectId: string, - dependencyName: string + dependencies: Array ) => ({ - type: ADD_DEPENDENCY_ERROR, + type: UNINSTALL_DEPENDENCIES_FINISH, projectId, - dependencyName, + dependencies, }); -export const addDependencyFinish = ( +export const queueDependencyInstall = ( projectId: string, - dependency: Dependency + name: string, + version: string, + updating?: boolean ) => ({ - type: ADD_DEPENDENCY_FINISH, + type: QUEUE_DEPENDENCY_INSTALL, + projectId, + name, + version, + updating, +}); + +export const queueDependencyUninstall = (projectId: string, name: string) => ({ + type: QUEUE_DEPENDENCY_UNINSTALL, + projectId, + name, +}); + +export const startNextActionInQueue = (projectId: string) => ({ + type: START_NEXT_ACTION_IN_QUEUE, projectId, - dependency, }); export const showImportExistingProjectPrompt = () => ({ diff --git a/src/components/AddDependencySearchResult/AddDependencySearchResult.js b/src/components/AddDependencySearchResult/AddDependencySearchResult.js index 0ca00ec2..70df5d8f 100644 --- a/src/components/AddDependencySearchResult/AddDependencySearchResult.js +++ b/src/components/AddDependencySearchResult/AddDependencySearchResult.js @@ -13,7 +13,6 @@ import { getSelectedProjectId, getDependencyMapForSelectedProject, } from '../../reducers/projects.reducer'; -import { getPackageJsonLockedForProjectId } from '../../reducers/package-json-locked.reducer'; import { COLORS } from '../../constants'; import Spacer from '../Spacer'; @@ -26,11 +25,20 @@ import CustomHighlight from '../CustomHighlight'; import type { DependencyStatus } from '../../types'; +const DEPENDENCY_ACTIONS_COPY = { + idle: 'Installed', + installing: 'Installing…', + updating: 'Updating…', + deleting: 'Deleting…', + 'queued-install': 'Queued for Install', + 'queued-update': 'Queued for Update', + 'queued-delete': 'Queued for Delete', +}; + type Props = { projectId: string, currentStatus: ?DependencyStatus, - isPackageJsonLocked: boolean, - addDependencyStart: ( + addDependency: ( projectId: string, dependencyName: string, version: string @@ -77,23 +85,10 @@ const getColorForDownloadNumber = (num: number) => { class AddDependencySearchResult extends PureComponent { renderActionArea() { - const { - hit, - projectId, - currentStatus, - isPackageJsonLocked, - addDependencyStart, - } = this.props; - - if (currentStatus === 'installing') { - return ( - - - - Installing... - - ); - } else if (typeof currentStatus === 'string') { + const { hit, projectId, currentStatus, addDependency } = this.props; + const isAlreadyInstalled = currentStatus === 'idle'; + + if (isAlreadyInstalled) { return ( { Installed ); - } else { + } + + if (currentStatus) { return ( - + + + + {DEPENDENCY_ACTIONS_COPY[currentStatus]} + ); } + + return ( + + ); } render() { @@ -273,14 +277,10 @@ const mapStateToProps = (state, ownProps) => { return { currentStatus, projectId: selectedProjectId, - isPackageJsonLocked: getPackageJsonLockedForProjectId( - state, - selectedProjectId - ), }; }; -const mapDispatchToProps = { addDependencyStart: actions.addDependencyStart }; +const mapDispatchToProps = { addDependency: actions.addDependency }; export default connect( mapStateToProps, diff --git a/src/components/DeleteDependencyButton/DeleteDependencyButton.js b/src/components/DeleteDependencyButton/DeleteDependencyButton.js index 68442a4c..cbe958cc 100644 --- a/src/components/DeleteDependencyButton/DeleteDependencyButton.js +++ b/src/components/DeleteDependencyButton/DeleteDependencyButton.js @@ -5,7 +5,6 @@ import { remote } from 'electron'; import * as actions from '../../actions'; import { COLORS } from '../../constants'; -import { getPackageJsonLockedForProjectId } from '../../reducers/package-json-locked.reducer'; import Button from '../Button'; import Spinner from '../Spinner'; @@ -13,13 +12,17 @@ import PixelShifter from '../PixelShifter'; const { dialog } = remote; +const DEPENDENCY_DELETE_COPY = { + idle: 'Delete', + 'queued-delete': 'Queued for Delete…', +}; + type Props = { projectId: string, dependencyName: string, - isBeingDeleted?: boolean, + dependencyStatus: string, // From redux: - isPackageJsonLocked: boolean, - deleteDependencyStart: (projectId: string, dependencyName: string) => any, + deleteDependency: (projectId: string, dependencyName: string) => any, }; // TODO: Wouldn't it be neat if it parsed your project to see if it was being @@ -27,7 +30,16 @@ type Props = { // an actively-used dependency? class DeleteDependencyButton extends PureComponent { handleClick = () => { - const { projectId, dependencyName, deleteDependencyStart } = this.props; + const { + projectId, + dependencyName, + deleteDependency, + dependencyStatus, + } = this.props; + + // if the dependency is currently changing/queued for change, + // this button shouldn't do anything + if (dependencyStatus !== 'idle') return; dialog.showMessageBox( { @@ -44,14 +56,15 @@ class DeleteDependencyButton extends PureComponent { const isConfirmed = response === 0; if (isConfirmed) { - deleteDependencyStart(projectId, dependencyName); + deleteDependency(projectId, dependencyName); } } ); }; render() { - const { isBeingDeleted, isPackageJsonLocked } = this.props; + const { dependencyStatus } = this.props; + return ( ); } } -const mapStateToProps = (state, ownProps) => ({ - isPackageJsonLocked: getPackageJsonLockedForProjectId( - state, - ownProps.projectId - ), -}); - export default connect( - mapStateToProps, - { deleteDependencyStart: actions.deleteDependencyStart } + null, + { deleteDependency: actions.deleteDependency } )(DeleteDependencyButton); diff --git a/src/components/DependencyDetailsTable/DependencyDetailsTable.js b/src/components/DependencyDetailsTable/DependencyDetailsTable.js index d2432ca7..8509a4e8 100644 --- a/src/components/DependencyDetailsTable/DependencyDetailsTable.js +++ b/src/components/DependencyDetailsTable/DependencyDetailsTable.js @@ -107,7 +107,7 @@ class DependencyDetailsTable extends Component { diff --git a/src/components/DependencyInstalling/DependencyInstalling.js b/src/components/DependencyInstalling/DependencyInstalling.js index 4aaeef1a..460b3143 100644 --- a/src/components/DependencyInstalling/DependencyInstalling.js +++ b/src/components/DependencyInstalling/DependencyInstalling.js @@ -1,5 +1,5 @@ // @flow -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import styled from 'styled-components'; import guppyLoaderSrc from '../../assets/images/guppy-loader.gif'; @@ -10,11 +10,15 @@ import Spacer from '../Spacer'; type Props = { name: string, + queued: boolean, }; class DependencyInstalling extends Component { render() { - const { name } = this.props; + const { name, queued } = this.props; + const stylizedName = ( + {name} + ); return ( @@ -22,7 +26,11 @@ class DependencyInstalling extends Component { - Installing {name}… + {queued ? ( + {stylizedName} is queued for install... + ) : ( + Installing {stylizedName}... + )} diff --git a/src/components/DependencyManagementPane/DependencyManagementPane.js b/src/components/DependencyManagementPane/DependencyManagementPane.js index 0d410ebc..654c2d72 100644 --- a/src/components/DependencyManagementPane/DependencyManagementPane.js +++ b/src/components/DependencyManagementPane/DependencyManagementPane.js @@ -63,9 +63,10 @@ class DependencyManagementPane extends PureComponent { } // If the last dependency was deleted, we need to shift focus to the new last dependency - // in the list. + // in the list. It's possible that a group of dependencies was deleted from the end of + // the list as a batch, so check >= and not just ===. if ( - this.state.selectedDependencyIndex === + this.state.selectedDependencyIndex >= nextProps.project.dependencies.length ) { this.setState({ @@ -101,6 +102,44 @@ class DependencyManagementPane extends PureComponent { this.setState({ addingNewDependency: false }); }; + renderListAddon = (dependency, isSelected) => { + if ( + dependency.status === 'installing' || + dependency.status.match(/^queued-/) + ) { + return ( + + ); + } + + return ( + + {dependency.version} + + ); + }; + + renderMainContents = (selectedDependency, projectId) => { + if ( + selectedDependency.status === 'installing' || + selectedDependency.status === 'queued-install' + ) { + return ( + + ); + } + + return ( + + ); + }; + render() { const { id, dependencies } = this.props.project; const { selectedDependencyIndex, addingNewDependency } = this.state; @@ -122,21 +161,9 @@ class DependencyManagementPane extends PureComponent { onClick={() => this.selectDependency(dependency.name)} > {dependency.name} - {dependency.status === 'installing' ? ( - - ) : ( - - {dependency.version} - + {this.renderListAddon( + dependency, + selectedDependencyIndex === index )} ))} @@ -170,14 +197,7 @@ class DependencyManagementPane extends PureComponent { - {selectedDependency.status === 'installing' ? ( - - ) : ( - - )} + {this.renderMainContents(selectedDependency, id)} diff --git a/src/components/DependencyUpdateRow/DependencyUpdateRow.js b/src/components/DependencyUpdateRow/DependencyUpdateRow.js index 476f84ac..1e516439 100644 --- a/src/components/DependencyUpdateRow/DependencyUpdateRow.js +++ b/src/components/DependencyUpdateRow/DependencyUpdateRow.js @@ -7,7 +7,6 @@ import { check } from 'react-icons-kit/feather/check'; import * as actions from '../../actions'; import { COLORS } from '../../constants'; -import { getPackageJsonLockedForProjectId } from '../../reducers/package-json-locked.reducer'; import Button from '../Button'; import Label from '../Label'; @@ -23,8 +22,7 @@ type Props = { isLoadingNpmInfo: boolean, latestVersion: ?string, // From redux: - isPackageJsonLocked: boolean, - updateDependencyStart: ( + updateDependency: ( projectId: string, dependencyName: string, latestVersion: string @@ -38,8 +36,7 @@ class DependencyUpdateRow extends Component { dependency, isLoadingNpmInfo, latestVersion, - updateDependencyStart, - isPackageJsonLocked, + updateDependency, } = this.props; if (isLoadingNpmInfo || !latestVersion) { @@ -66,9 +63,8 @@ class DependencyUpdateRow extends Component { color1={COLORS.green[700]} color2={COLORS.lightGreen[500]} style={{ width: 80 }} - disabled={isPackageJsonLocked} onClick={() => - updateDependencyStart(projectId, dependency.name, latestVersion) + updateDependency(projectId, dependency.name, latestVersion) } > {isUpdating ? : 'Update'} @@ -129,14 +125,7 @@ const UpToDate = styled.div` font-weight: 500; `; -const mapStateToProps = (state, ownProps) => ({ - isPackageJsonLocked: getPackageJsonLockedForProjectId( - state, - ownProps.projectId - ), -}); - export default connect( - mapStateToProps, - { updateDependencyStart: actions.updateDependencyStart } + null, + { updateDependency: actions.updateDependency } )(DependencyUpdateRow); diff --git a/src/reducers/dependencies.reducer.js b/src/reducers/dependencies.reducer.js index 8a0d76de..94095b14 100644 --- a/src/reducers/dependencies.reducer.js +++ b/src/reducers/dependencies.reducer.js @@ -2,15 +2,15 @@ import produce from 'immer'; import { LOAD_DEPENDENCY_INFO_FROM_DISK, - DELETE_DEPENDENCY_START, - DELETE_DEPENDENCY_ERROR, - DELETE_DEPENDENCY_FINISH, - UPDATE_DEPENDENCY_START, - UPDATE_DEPENDENCY_ERROR, - UPDATE_DEPENDENCY_FINISH, - ADD_DEPENDENCY_START, - ADD_DEPENDENCY_ERROR, - ADD_DEPENDENCY_FINISH, + ADD_DEPENDENCY, + UPDATE_DEPENDENCY, + DELETE_DEPENDENCY, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_START, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, } from '../actions'; import type { Action } from 'redux'; @@ -35,13 +35,13 @@ export default (state: State = initialState, action: Action) => { }; } - case ADD_DEPENDENCY_START: { + case ADD_DEPENDENCY: { const { projectId, dependencyName } = action; return produce(state, draftState => { draftState[projectId][dependencyName] = { name: dependencyName, - status: 'installing', + status: 'queued-install', // All of the other fields are unknown at this point. // To make life simpler, we'll set them to empty strings, // rather than deal with nullable fields everywhere else. @@ -55,69 +55,86 @@ export default (state: State = initialState, action: Action) => { }); } - case ADD_DEPENDENCY_ERROR: { + case UPDATE_DEPENDENCY: { const { projectId, dependencyName } = action; return produce(state, draftState => { - // If the dependency couldn't be installed, we should remove it from - // state. - delete draftState[projectId][dependencyName]; + draftState[projectId][dependencyName].status = 'queued-update'; }); } - case ADD_DEPENDENCY_FINISH: { - const { projectId, dependency } = action; + case DELETE_DEPENDENCY: { + const { projectId, dependencyName } = action; return produce(state, draftState => { - draftState[projectId][dependency.name] = dependency; + draftState[projectId][dependencyName].status = 'queued-delete'; }); } - case UPDATE_DEPENDENCY_START: { - const { projectId, dependencyName } = action; + case INSTALL_DEPENDENCIES_START: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId][dependencyName].status = 'updating'; + dependencies.forEach(dependency => { + draftState[projectId][dependency.name].status = dependency.updating + ? 'updating' + : 'installing'; + }); }); } - case UPDATE_DEPENDENCY_ERROR: { - const { projectId, dependencyName } = action; + case INSTALL_DEPENDENCIES_ERROR: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId][dependencyName].status = 'idle'; + dependencies.forEach(dependency => { + if (dependency.updating) { + draftState[projectId][dependency.name].status = 'idle'; + } else { + delete draftState[projectId][dependency.name]; + } + }); }); } - case UPDATE_DEPENDENCY_FINISH: { - const { projectId, dependencyName, latestVersion } = action; + case INSTALL_DEPENDENCIES_FINISH: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId][dependencyName].version = latestVersion; + dependencies.forEach(dependency => { + draftState[projectId][dependency.name] = dependency; + draftState[projectId][dependency.name].status = 'idle'; + }); }); } - case DELETE_DEPENDENCY_START: { - const { projectId, dependencyName } = action; + case UNINSTALL_DEPENDENCIES_START: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId][dependencyName].status = 'deleting'; + dependencies.forEach(dependency => { + draftState[projectId][dependency.name].status = 'deleting'; + }); }); } - case DELETE_DEPENDENCY_ERROR: { - const { projectId, dependencyName } = action; + case UNINSTALL_DEPENDENCIES_ERROR: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId][dependencyName].status = 'idle'; + dependencies.forEach(dependency => { + draftState[projectId][dependency.name].status = 'idle'; + }); }); } - case DELETE_DEPENDENCY_FINISH: { - const { projectId, dependencyName } = action; + case UNINSTALL_DEPENDENCIES_FINISH: { + const { projectId, dependencies } = action; return produce(state, draftState => { - delete draftState[projectId][dependencyName]; + dependencies.forEach(dependency => { + delete draftState[projectId][dependency.name]; + }); }); } diff --git a/src/reducers/dependencies.reducer.test.js b/src/reducers/dependencies.reducer.test.js index d09694c1..3821860c 100644 --- a/src/reducers/dependencies.reducer.test.js +++ b/src/reducers/dependencies.reducer.test.js @@ -1,15 +1,15 @@ import reducer, { getDependenciesForProjectId } from './dependencies.reducer'; import { LOAD_DEPENDENCY_INFO_FROM_DISK, - ADD_DEPENDENCY_START, - ADD_DEPENDENCY_ERROR, - ADD_DEPENDENCY_FINISH, - UPDATE_DEPENDENCY_START, - UPDATE_DEPENDENCY_ERROR, - UPDATE_DEPENDENCY_FINISH, - DELETE_DEPENDENCY_START, - DELETE_DEPENDENCY_ERROR, - DELETE_DEPENDENCY_FINISH, + ADD_DEPENDENCY, + UPDATE_DEPENDENCY, + DELETE_DEPENDENCY, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_START, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, } from '../actions'; describe('dependencies reducer', () => { @@ -38,13 +38,13 @@ Object { `); }); - it(`should handle ${ADD_DEPENDENCY_START}`, () => { + it(`should handle ${ADD_DEPENDENCY}`, () => { const prevState = { foo: {}, }; const action = { - type: ADD_DEPENDENCY_START, + type: ADD_DEPENDENCY, projectId: 'foo', dependencyName: 'redux', }; @@ -62,7 +62,7 @@ Object { "type": "", "url": "", }, - "status": "installing", + "status": "queued-install", "version": "", }, }, @@ -70,57 +70,77 @@ Object { `); }); - it(`should handle ${ADD_DEPENDENCY_ERROR}`, () => { + it(`should handle ${UPDATE_DEPENDENCY}`, () => { const prevState = { foo: { - redux: {}, - 'react-router': {}, + redux: { + name: 'redux', + status: 'idle', + location: 'dependencies', + description: 'dependency description', + keywords: ['key', 'words'], + version: '3.2', + homepage: 'https://dependency-homepage.io', + license: 'MIT', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, + }, }, }; const action = { - type: ADD_DEPENDENCY_ERROR, + type: UPDATE_DEPENDENCY, projectId: 'foo', dependencyName: 'redux', + latestVersion: '3.3', }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` Object { "foo": Object { - "react-router": Object {}, + "redux": Object { + "description": "dependency description", + "homepage": "https://dependency-homepage.io", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "queued-update", + "version": "3.2", + }, }, } `); }); - it(`should handle ${ADD_DEPENDENCY_FINISH}`, () => { + it(`should handle ${DELETE_DEPENDENCY}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'installing', - description: '', - homepage: '', - license: '', - repository: '', - version: '', + status: 'idle', + location: 'dependencies', + description: 'dependency description', + keywords: ['key', 'words'], + version: '3.2', + homepage: 'https://dependency-homepage.io', + license: 'MIT', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, }, }, }; const action = { - type: ADD_DEPENDENCY_FINISH, + type: DELETE_DEPENDENCY, projectId: 'foo', - dependency: { - name: 'redux', - status: 'idle', - description: 'dependency description', - keywords: ['key', 'words'], - version: '3.2', - homepage: 'https://dependency-homepage.io', - license: 'MIT', - repository: 'https://github.com', - }, + dependencyName: 'redux', + latestVersion: '3.3', }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` @@ -134,9 +154,13 @@ Object { "words", ], "license": "MIT", + "location": "dependencies", "name": "redux", - "repository": "https://github.com", - "status": "idle", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "queued-delete", "version": "3.2", }, }, @@ -144,31 +168,64 @@ Object { `); }); - it(`should handle ${UPDATE_DEPENDENCY_START}`, () => { + it(`should handle ${INSTALL_DEPENDENCIES_START}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'idle', + status: 'queued-update', + location: 'dependencies', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, + }, + 'react-redux': { + name: 'react-redux', + status: 'queued-install', + location: 'dependencies', + description: '', + version: '', + homepage: '', + license: '', + repository: { type: '', url: '' }, }, }, }; const action = { - type: UPDATE_DEPENDENCY_START, + type: INSTALL_DEPENDENCIES_START, projectId: 'foo', - dependencyName: 'redux', + dependencies: [ + { + name: 'redux', + version: '3.3', + updating: true, + }, + { + name: 'react-redux', + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` Object { "foo": Object { + "react-redux": Object { + "description": "", + "homepage": "", + "license": "", + "location": "dependencies", + "name": "react-redux", + "repository": Object { + "type": "", + "url": "", + }, + "status": "installing", + "version": "", + }, "redux": Object { "description": "dependency description", "homepage": "https://dependency-homepage.io", @@ -177,8 +234,12 @@ Object { "words", ], "license": "MIT", + "location": "dependencies", "name": "redux", - "repository": "https://github.com", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, "status": "updating", "version": "3.2", }, @@ -187,26 +248,46 @@ Object { `); }); - it(`should handle ${UPDATE_DEPENDENCY_ERROR}`, () => { + it(`should handle ${INSTALL_DEPENDENCIES_ERROR}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'updating', + status: 'queued-update', + location: 'dependencies', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, + }, + 'react-redux': { + name: 'react-redux', + status: 'queued-install', + location: 'dependencies', + description: '', + version: '', + homepage: '', + license: '', + repository: { type: '', url: '' }, }, }, }; const action = { - type: UPDATE_DEPENDENCY_ERROR, + type: INSTALL_DEPENDENCIES_ERROR, projectId: 'foo', - dependencyName: 'redux', + dependencies: [ + { + name: 'redux', + version: '3.3', + updating: true, + }, + { + name: 'react-redux', + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` @@ -220,8 +301,12 @@ Object { "words", ], "license": "MIT", + "location": "dependencies", "name": "redux", - "repository": "https://github.com", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, "status": "idle", "version": "3.2", }, @@ -230,32 +315,80 @@ Object { `); }); - it(`should handle ${UPDATE_DEPENDENCY_FINISH}`, () => { + it(`should handle ${INSTALL_DEPENDENCIES_FINISH}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'updating', + status: 'queued-update', + location: 'dependencies', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, + }, + 'react-redux': { + name: 'react-redux', + status: 'queued-install', + location: 'dependencies', + description: '', + version: '', + homepage: '', + license: '', + repository: { type: '', url: '' }, }, }, }; const action = { - type: UPDATE_DEPENDENCY_FINISH, + type: INSTALL_DEPENDENCIES_FINISH, projectId: 'foo', - dependencyName: 'redux', - latestVersion: '4.0', + dependencies: [ + { + name: 'redux', + location: 'dependencies', + description: 'dependency description', + keywords: ['key', 'words'], + version: '3.3', + homepage: 'https://dependency-homepage.io', + license: 'MIT', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, + }, + { + name: 'react-redux', + location: 'dependencies', + description: 'other description', + keywords: ['foo', 'bar'], + version: '3.0', + homepage: 'https://dependency-homepage2.io', + license: 'ISC', + repository: { type: 'git', url: 'https://github.com/bar/foo.git' }, + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` Object { "foo": Object { + "react-redux": Object { + "description": "other description", + "homepage": "https://dependency-homepage2.io", + "keywords": Array [ + "foo", + "bar", + ], + "license": "ISC", + "location": "dependencies", + "name": "react-redux", + "repository": Object { + "type": "git", + "url": "https://github.com/bar/foo.git", + }, + "status": "idle", + "version": "3.0", + }, "redux": Object { "description": "dependency description", "homepage": "https://dependency-homepage.io", @@ -264,36 +397,44 @@ Object { "words", ], "license": "MIT", + "location": "dependencies", "name": "redux", - "repository": "https://github.com", - "status": "updating", - "version": "4.0", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "idle", + "version": "3.3", }, }, } `); }); - it(`should handle ${DELETE_DEPENDENCY_START}`, () => { + it(`should handle ${UNINSTALL_DEPENDENCIES_START}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'idle', + status: 'queued-delete', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, }, }, }; const action = { - type: DELETE_DEPENDENCY_START, + type: UNINSTALL_DEPENDENCIES_START, projectId: 'foo', - dependencyName: 'redux', + dependencies: [ + { + name: 'redux', + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` @@ -308,7 +449,10 @@ Object { ], "license": "MIT", "name": "redux", - "repository": "https://github.com", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, "status": "deleting", "version": "3.2", }, @@ -317,26 +461,31 @@ Object { `); }); - it(`should handle ${DELETE_DEPENDENCY_ERROR}`, () => { + it(`should handle ${UNINSTALL_DEPENDENCIES_ERROR}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'deleting', + status: 'queued-delete', + location: 'dependencies', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, }, }, }; const action = { - type: DELETE_DEPENDENCY_ERROR, + type: UNINSTALL_DEPENDENCIES_ERROR, projectId: 'foo', - dependencyName: 'redux', + dependencies: [ + { + name: 'redux', + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` @@ -350,8 +499,12 @@ Object { "words", ], "license": "MIT", + "location": "dependencies", "name": "redux", - "repository": "https://github.com", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, "status": "idle", "version": "3.2", }, @@ -360,26 +513,31 @@ Object { `); }); - it(`should handle ${DELETE_DEPENDENCY_FINISH}`, () => { + it(`should handle ${UNINSTALL_DEPENDENCIES_FINISH}`, () => { const prevState = { foo: { redux: { name: 'redux', - status: 'deleting', + status: 'queued-delete', + location: 'dependencies', description: 'dependency description', keywords: ['key', 'words'], version: '3.2', homepage: 'https://dependency-homepage.io', license: 'MIT', - repository: 'https://github.com', + repository: { type: 'git', url: 'https://github.com/foo/bar.git' }, }, }, }; const action = { - type: DELETE_DEPENDENCY_FINISH, + type: UNINSTALL_DEPENDENCIES_FINISH, projectId: 'foo', - dependencyName: 'redux', + dependencies: [ + { + name: 'redux', + }, + ], }; expect(reducer(prevState, action)).toMatchInlineSnapshot(` diff --git a/src/reducers/index.js b/src/reducers/index.js index dd7dbfbd..7cbbc3b7 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -6,8 +6,8 @@ import tasks from './tasks.reducer'; import dependencies from './dependencies.reducer'; import modal from './modal.reducer'; import onboardingStatus from './onboarding-status.reducer'; -import packageJsonLocked from './package-json-locked.reducer'; import paths from './paths.reducer'; +import queue from './queue.reducer'; export default combineReducers({ appLoaded, @@ -16,6 +16,6 @@ export default combineReducers({ dependencies, modal, onboardingStatus, - packageJsonLocked, paths, + queue, }); diff --git a/src/reducers/package-json-locked.reducer.js b/src/reducers/package-json-locked.reducer.js deleted file mode 100644 index be3a3f11..00000000 --- a/src/reducers/package-json-locked.reducer.js +++ /dev/null @@ -1,92 +0,0 @@ -// @flow -/** - * Unlike a terminal, Guppy isn't locked up when performing actions like - * installing or updating dependencies. - * - * This presents a problem, though; if the user quickly tries to update multiple - * dependencies, there's a good chance that all but the first will fail. This - * is because the first process writes to the package.json, and locks that file. - * When the second dependency update tries to write to the file, it throws an - * error. - * - * Eventually, it would be good to build a queue system, to abstract this away - * from the user, so that they can just do whatever they want and we'll smartly - * group actions when possible, and sequence them when not. - * - * For now, though, we'll just lock package-json-type actions while something - * is in progress. And to track that, we have this simple reducer. - * - * Every project has its own package.json, and they shouldn't block each other. - * So this is on a per-project basis. - */ -import { - REFRESH_PROJECTS_FINISH, - ADD_PROJECT, - ADD_DEPENDENCY_START, - ADD_DEPENDENCY_ERROR, - ADD_DEPENDENCY_FINISH, - UPDATE_DEPENDENCY_START, - UPDATE_DEPENDENCY_ERROR, - UPDATE_DEPENDENCY_FINISH, - DELETE_DEPENDENCY_START, - DELETE_DEPENDENCY_ERROR, - DELETE_DEPENDENCY_FINISH, -} from '../actions'; - -import type { Action } from 'redux'; - -type State = { - [projectId: string]: boolean, -}; - -const initialState = {}; - -export default (state: State = initialState, action: Action) => { - switch (action.type) { - case REFRESH_PROJECTS_FINISH: - return Object.keys(action.projects).reduce( - (acc, projectId) => ({ - ...acc, - [projectId]: false, - }), - {} - ); - - case ADD_PROJECT: - return { - ...state, - [action.project.id]: false, - }; - - case ADD_DEPENDENCY_START: - case UPDATE_DEPENDENCY_START: - case DELETE_DEPENDENCY_START: - return { - ...state, - [action.projectId]: true, - }; - - case ADD_DEPENDENCY_ERROR: - case ADD_DEPENDENCY_FINISH: - case UPDATE_DEPENDENCY_ERROR: - case UPDATE_DEPENDENCY_FINISH: - case DELETE_DEPENDENCY_ERROR: - case DELETE_DEPENDENCY_FINISH: - return { - ...state, - [action.projectId]: false, - }; - - default: - return state; - } -}; - -// -// -// -// Selectors -export const getPackageJsonLockedForProjectId = ( - state: any, - projectId: string -) => state.packageJsonLocked[projectId]; diff --git a/src/reducers/projects.reducer.js b/src/reducers/projects.reducer.js index 845f0404..db1e40cb 100644 --- a/src/reducers/projects.reducer.js +++ b/src/reducers/projects.reducer.js @@ -6,7 +6,7 @@ import { ADD_PROJECT, IMPORT_EXISTING_PROJECT_FINISH, FINISH_DELETING_PROJECT, - ADD_DEPENDENCY_FINISH, + INSTALL_DEPENDENCIES_FINISH, REFRESH_PROJECTS_FINISH, SELECT_PROJECT, } from '../actions'; @@ -47,12 +47,14 @@ const byIdReducer = (state: ById = initialState.byId, action: Action) => { }; } - case ADD_DEPENDENCY_FINISH: { - const { projectId, dependency } = action; + case INSTALL_DEPENDENCIES_FINISH: { + const { projectId, dependencies } = action; return produce(state, draftState => { - draftState[projectId].dependencies[dependency.name] = - dependency.version; + dependencies.forEach(dependency => { + draftState[projectId].dependencies[dependency.name] = + dependency.version; + }); }); } diff --git a/src/reducers/projects.reducer.test.js b/src/reducers/projects.reducer.test.js index b979be85..1262fab6 100644 --- a/src/reducers/projects.reducer.test.js +++ b/src/reducers/projects.reducer.test.js @@ -1,6 +1,6 @@ import { IMPORT_EXISTING_PROJECT_FINISH, - ADD_DEPENDENCY_FINISH, + INSTALL_DEPENDENCIES_FINISH, REFRESH_PROJECTS_FINISH, SELECT_PROJECT, ADD_PROJECT, @@ -92,21 +92,23 @@ describe('Projects Reducer', () => { }); }); - describe(ADD_DEPENDENCY_FINISH, () => { + describe(INSTALL_DEPENDENCIES_FINISH, () => { it('adds dependency to project dependencies', () => { const action = { - type: ADD_DEPENDENCY_FINISH, + type: INSTALL_DEPENDENCIES_FINISH, projectId: 'foo', - dependency: { - description: 'Package', - homepage: 'http://example.com/', - keywords: [], - license: 'MIT', - name: 'package', - repository: {}, - status: 'idle', - version: '4.0.0', - }, + dependencies: [ + { + description: 'Package', + homepage: 'http://example.com/', + keywords: [], + license: 'MIT', + name: 'package', + repository: {}, + status: 'idle', + version: '4.0.0', + }, + ], }; const initialState = { @@ -132,7 +134,7 @@ describe('Projects Reducer', () => { foo: { ...initialState.byId.foo, dependencies: { - [action.dependency.name]: action.dependency.version, + [action.dependencies[0].name]: action.dependencies[0].version, }, }, }, diff --git a/src/reducers/queue.reducer.js b/src/reducers/queue.reducer.js new file mode 100644 index 00000000..f320b758 --- /dev/null +++ b/src/reducers/queue.reducer.js @@ -0,0 +1,136 @@ +// @flow +import produce from 'immer'; +import { + QUEUE_DEPENDENCY_INSTALL, + QUEUE_DEPENDENCY_UNINSTALL, + INSTALL_DEPENDENCIES_START, + UNINSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, +} from '../actions'; + +import type { Action } from 'redux'; +import type { QueuedDependency, QueueAction } from '../types'; + +type QueueEntry = { + action: QueueAction, + active: boolean, + dependencies: Array, +}; + +type State = { + [projectId: string]: Array, +}; + +const initialState = {}; + +export default (state: State = initialState, action: Action) => { + switch (action.type) { + case QUEUE_DEPENDENCY_INSTALL: { + const { projectId, name, version, updating } = action; + + return produce(state, draftState => { + // get existing project queue, or create it if this + // is the first entry + const projectQueue = draftState[projectId] || []; + + // get existing install queue for this project, or + // create it if it doesn't exist + let installQueue = projectQueue.find( + q => q.action === 'install' && !q.active + ); + if (!installQueue) { + installQueue = { + action: 'install', + active: false, + dependencies: [], + }; + projectQueue.push(installQueue); + } + + // add dependency to the install queue + installQueue.dependencies.push({ + name, + version, + updating, + }); + + // update the project's install queue + draftState[projectId] = projectQueue; + }); + } + + case QUEUE_DEPENDENCY_UNINSTALL: { + const { projectId, name } = action; + + return produce(state, draftState => { + // get existing project queue, or create it if this + // is the first entry + const projectQueue = draftState[projectId] || []; + + // get existing uninstall queue for this project, or + // create it if it doesn't exist + let installQueue = projectQueue.find( + q => q.action === 'uninstall' && !q.active + ); + if (!installQueue) { + installQueue = { + action: 'uninstall', + active: false, + dependencies: [], + }; + projectQueue.push(installQueue); + } + + // add dependency to the uninstall queue + installQueue.dependencies.push({ + name, + }); + + // update the project's uninstall queue + draftState[projectId] = projectQueue; + }); + } + + case INSTALL_DEPENDENCIES_START: + case UNINSTALL_DEPENDENCIES_START: { + const { projectId } = action; + + return produce(state, draftState => { + // mark the next item in the queue as active + draftState[projectId][0].active = true; + }); + } + + case INSTALL_DEPENDENCIES_ERROR: + case INSTALL_DEPENDENCIES_FINISH: + case UNINSTALL_DEPENDENCIES_ERROR: + case UNINSTALL_DEPENDENCIES_FINISH: { + const { projectId } = action; + + return produce(state, draftState => { + // remove oldest item in queue (it's just been + // completed by the dependency saga) + draftState[projectId].shift(); + + // remove the project's queue if this was the + // last entry + if (draftState[projectId].length === 0) { + delete draftState[projectId]; + } + }); + } + + default: + return state; + } +}; + +// +// +// +// Selectors +export const getNextActionForProjectId = (state: any, projectId: string) => + state.queue[projectId] && state.queue[projectId][0]; diff --git a/src/reducers/queue.reducer.test.js b/src/reducers/queue.reducer.test.js new file mode 100644 index 00000000..f9437e57 --- /dev/null +++ b/src/reducers/queue.reducer.test.js @@ -0,0 +1,324 @@ +import reducer, { getNextActionForProjectId } from './queue.reducer'; +import { + QUEUE_DEPENDENCY_INSTALL, + QUEUE_DEPENDENCY_UNINSTALL, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_FINISH, +} from '../actions'; + +describe('queue reducer', () => { + it('should return initial state', () => { + expect(reducer(undefined, {})).toEqual({}); + }); + + it(`should handle queue item start`, () => { + const prevState = { + foo: [ + { action: 'install', active: false, dependencies: [{ name: 'redux' }] }, + ], + }; + + const action = { + type: INSTALL_DEPENDENCIES_START, + projectId: 'foo', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "active": true, + "dependencies": Array [ + Object { + "name": "redux", + }, + ], + }, + ], +} +`); + }); + + it(`should handle queue item completion for queue with next action`, () => { + const prevState = { + foo: [ + { action: 'install', dependencies: [{ name: 'redux' }] }, + { action: 'uninstall', dependencies: [{ name: 'react-redux' }] }, + ], + }; + + const action = { + type: INSTALL_DEPENDENCIES_FINISH, + projectId: 'foo', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + ], + }, + ], +} +`); + }); + + it(`should handle queue item completion for queue with no more actions`, () => { + const prevState = { + foo: [{ action: 'install', dependencies: [{ name: 'redux' }] }], + }; + + const action = { + type: INSTALL_DEPENDENCIES_FINISH, + projectId: 'foo', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(`Object {}`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_INSTALL} for new dependency on empty queue`, () => { + const prevState = {}; + + const action = { + type: QUEUE_DEPENDENCY_INSTALL, + projectId: 'foo', + name: 'redux', + version: '3.2', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "active": false, + "dependencies": Array [ + Object { + "name": "redux", + "updating": undefined, + "version": "3.2", + }, + ], + }, + ], +} +`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_INSTALL} for new dependency on existing queue`, () => { + const prevState = { + foo: [ + { + action: 'install', + dependencies: [{ name: 'react-redux' }], + }, + ], + }; + + const action = { + type: QUEUE_DEPENDENCY_INSTALL, + projectId: 'foo', + name: 'redux', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + Object { + "name": "redux", + "updating": undefined, + "version": undefined, + }, + ], + }, + ], +} +`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_INSTALL} for updating dependency`, () => { + const prevState = {}; + + const action = { + type: QUEUE_DEPENDENCY_INSTALL, + projectId: 'foo', + name: 'redux', + version: '3.3', + updating: true, + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "active": false, + "dependencies": Array [ + Object { + "name": "redux", + "updating": true, + "version": "3.3", + }, + ], + }, + ], +} +`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_UNINSTALL}`, () => { + const prevState = {}; + + const action = { + type: QUEUE_DEPENDENCY_UNINSTALL, + projectId: 'foo', + name: 'redux', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "uninstall", + "active": false, + "dependencies": Array [ + Object { + "name": "redux", + }, + ], + }, + ], +} +`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_INSTALL} for mixed existing queue`, () => { + const prevState = { + foo: [ + { action: 'install', dependencies: [{ name: 'react-redux' }] }, + { action: 'uninstall', dependencies: [{ name: 'redux' }] }, + ], + }; + + const action = { + type: QUEUE_DEPENDENCY_INSTALL, + projectId: 'foo', + name: 'lodash', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + Object { + "name": "lodash", + "updating": undefined, + "version": undefined, + }, + ], + }, + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "redux", + }, + ], + }, + ], +} +`); + }); + + it(`should handle ${QUEUE_DEPENDENCY_UNINSTALL} for mixed existing queue`, () => { + const prevState = { + foo: [ + { action: 'install', dependencies: [{ name: 'react-redux' }] }, + { action: 'uninstall', dependencies: [{ name: 'redux' }] }, + ], + }; + + const action = { + type: QUEUE_DEPENDENCY_UNINSTALL, + projectId: 'foo', + name: 'lodash', + }; + + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + ], + }, + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "redux", + }, + Object { + "name": "lodash", + }, + ], + }, + ], +} +`); + }); + + describe('getNextActionForProjectId', () => { + it('should return next action when one is present', () => { + const state = { + queue: { + foo: [{ action: 'install', dependencies: [{ name: 'redux' }] }], + }, + }; + + const projectId = 'foo'; + + expect(getNextActionForProjectId(state, projectId)) + .toMatchInlineSnapshot(` +Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "redux", + }, + ], +} +`); + }); + + it('should return undefined when no actions are present', () => { + const state = { + queue: {}, + }; + + const projectId = 'foo'; + + expect(getNextActionForProjectId(state, projectId)).toBe(undefined); + }); + }); +}); diff --git a/src/sagas/dependency.saga.js b/src/sagas/dependency.saga.js index f02f9223..4b32acbc 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -1,92 +1,174 @@ // @flow import { select, call, put, takeEvery } from 'redux-saga/effects'; import { getPathForProjectId } from '../reducers/paths.reducer'; +import { getNextActionForProjectId } from '../reducers/queue.reducer'; import { - installDependency, - uninstallDependency, + installDependencies, + uninstallDependencies, } from '../services/dependencies.service'; -import { loadProjectDependency } from '../services/read-from-disk.service'; +import { loadProjectDependencies } from '../services/read-from-disk.service'; import { - ADD_DEPENDENCY_START, - UPDATE_DEPENDENCY_START, - DELETE_DEPENDENCY_START, - addDependencyFinish, - addDependencyError, - updateDependencyFinish, - updateDependencyError, - deleteDependencyFinish, - deleteDependencyError, + ADD_DEPENDENCY, + UPDATE_DEPENDENCY, + DELETE_DEPENDENCY, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_START, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, + START_NEXT_ACTION_IN_QUEUE, + queueDependencyInstall, + queueDependencyUninstall, + installDependencyStart, + installDependenciesStart, + installDependenciesError, + installDependenciesFinish, + uninstallDependencyStart, + uninstallDependenciesStart, + uninstallDependenciesError, + uninstallDependenciesFinish, + startNextActionInQueue, } from '../actions'; import type { Action } from 'redux'; import type { Saga } from 'redux-saga'; -/** - * Trying to install new dependency, if success dispatching "finish" action - * if not - dispatching "error" ection - */ -export function* addDependency({ +export function* handleAddDependency({ projectId, dependencyName, version, }: Action): Saga { - const projectPath = yield select(getPathForProjectId, projectId); - try { - yield call(installDependency, projectPath, dependencyName, version); - const dependency = yield call( - loadProjectDependency, - projectPath, - dependencyName - ); - yield put(addDependencyFinish(projectId, dependency)); - } catch (err) { - yield call([console, 'error'], 'Failed to install dependency', err); - yield put(addDependencyError(projectId, dependencyName)); + const queuedAction = yield select(getNextActionForProjectId, projectId); + + yield put(queueDependencyInstall(projectId, dependencyName, version)); + + // if there are no other ongoing operations, begin install + if (!queuedAction) { + yield put(installDependencyStart(projectId, dependencyName, version)); } } -/** - * Trying to update existing dependency, if success dispatching "finish" action, - * if not - dispatching "error" action - */ -export function* updateDependency({ +export function* handleUpdateDependency({ projectId, dependencyName, latestVersion, +}: Action): Saga { + const queuedAction = yield select(getNextActionForProjectId, projectId); + + yield put( + queueDependencyInstall(projectId, dependencyName, latestVersion, true) + ); + + if (!queuedAction) { + yield put( + installDependencyStart(projectId, dependencyName, latestVersion, true) + ); + } +} + +export function* handleDeleteDependency({ + projectId, + dependencyName, +}: Action): Saga { + const queuedAction = yield select(getNextActionForProjectId, projectId); + + yield put(queueDependencyUninstall(projectId, dependencyName)); + + if (!queuedAction) { + yield put(uninstallDependencyStart(projectId, dependencyName)); + } +} + +export function* handleInstallDependenciesStart({ + projectId, + dependencies, }: Action): Saga { const projectPath = yield select(getPathForProjectId, projectId); + try { - yield call(installDependency, projectPath, dependencyName, latestVersion); - yield put(updateDependencyFinish(projectId, dependencyName, latestVersion)); + yield call(installDependencies, projectPath, dependencies); + const storedDependencies = yield call( + loadProjectDependencies, + projectPath, + dependencies + ); + yield put(installDependenciesFinish(projectId, storedDependencies)); } catch (err) { - yield call([console, 'error'], 'Failed to update dependency', err); - yield put(updateDependencyError(projectId, dependencyName)); + yield call([console, console.error], 'Failed to install dependencies', err); + yield put(installDependenciesError(projectId, dependencies)); } } -/** - * Trying to delete dependency, if success dispatching "finish" action, - * if not - dispatching "error" action - */ -export function* deleteDependency({ +export function* handleUninstallDependenciesStart({ projectId, - dependencyName, + dependencies, }: Action): Saga { const projectPath = yield select(getPathForProjectId, projectId); + try { - yield call(uninstallDependency, projectPath, dependencyName); - yield put(deleteDependencyFinish(projectId, dependencyName)); + yield call(uninstallDependencies, projectPath, dependencies); + yield put(uninstallDependenciesFinish(projectId, dependencies)); } catch (err) { - yield call([console, 'error'], 'Failed to delete dependency', err); - yield put(deleteDependencyError(projectId, dependencyName)); + yield call( + [console, console.error], + 'Failed to uninstall dependencies', + err + ); + yield put(uninstallDependenciesError(projectId, dependencies)); + } +} + +export function* handleQueueActionCompleted({ projectId }: Action): Saga { + const nextAction = yield select(getNextActionForProjectId, projectId); + + // if there is another item in the queue, start it + if (nextAction) { + yield put(startNextActionInQueue(projectId)); + } +} + +export function* handleStartNextActionInQueue({ + projectId, +}: Action): Saga { + const nextAction = yield select(getNextActionForProjectId, projectId); + + // if the queue is empty, log an error + if (!nextAction) { + return console.error( + `attempted to start next action in empty queue for project ${projectId}` + ); } + + const actionCreator = + nextAction.action === 'install' + ? installDependenciesStart + : uninstallDependenciesStart; + yield put(actionCreator(projectId, nextAction.dependencies)); } -/** - * Root dependencies saga, watching for "start" actions - */ +// Installs/uninstalls fail silently - the only notice of a failed action +// visible to the user is either the dependency disappearing entirely or +// having its status set back to `idle`. +// TODO: display an error message outside of the console when a dependency +// action fails export default function* rootSaga(): Saga { - yield takeEvery(ADD_DEPENDENCY_START, addDependency); - yield takeEvery(UPDATE_DEPENDENCY_START, updateDependency); - yield takeEvery(DELETE_DEPENDENCY_START, deleteDependency); + yield takeEvery(ADD_DEPENDENCY, handleAddDependency); + yield takeEvery(UPDATE_DEPENDENCY, handleUpdateDependency); + yield takeEvery(DELETE_DEPENDENCY, handleDeleteDependency); + yield takeEvery(INSTALL_DEPENDENCIES_START, handleInstallDependenciesStart); + yield takeEvery( + UNINSTALL_DEPENDENCIES_START, + handleUninstallDependenciesStart + ); + yield takeEvery( + [ + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, + ], + handleQueueActionCompleted + ); + yield takeEvery(START_NEXT_ACTION_IN_QUEUE, handleStartNextActionInQueue); } diff --git a/src/sagas/dependency.saga.test.js b/src/sagas/dependency.saga.test.js index ba936247..e0297704 100644 --- a/src/sagas/dependency.saga.test.js +++ b/src/sagas/dependency.saga.test.js @@ -1,33 +1,51 @@ import { select, call, put, takeEvery } from 'redux-saga/effects'; import rootSaga, { - addDependency, - updateDependency, - deleteDependency, + handleAddDependency, + handleUpdateDependency, + handleDeleteDependency, + handleInstallDependenciesStart, + handleUninstallDependenciesStart, + handleQueueActionCompleted, + handleStartNextActionInQueue, } from './dependency.saga'; import { getPathForProjectId } from '../reducers/paths.reducer'; +import { getNextActionForProjectId } from '../reducers/queue.reducer'; import { - installDependency, - uninstallDependency, + installDependencies, + uninstallDependencies, } from '../services/dependencies.service'; -import { loadProjectDependency } from '../services/read-from-disk.service'; +import { loadProjectDependencies } from '../services/read-from-disk.service'; import { - addDependencyFinish, - addDependencyError, - updateDependencyFinish, - updateDependencyError, - deleteDependencyFinish, - deleteDependencyError, - ADD_DEPENDENCY_START, - UPDATE_DEPENDENCY_START, - DELETE_DEPENDENCY_START, + ADD_DEPENDENCY, + UPDATE_DEPENDENCY, + DELETE_DEPENDENCY, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_START, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, + START_NEXT_ACTION_IN_QUEUE, + queueDependencyInstall, + queueDependencyUninstall, + installDependencyStart, + installDependenciesStart, + installDependenciesError, + installDependenciesFinish, + uninstallDependencyStart, + uninstallDependenciesStart, + uninstallDependenciesError, + uninstallDependenciesFinish, + startNextActionInQueue, } from '../actions'; -jest.mock('../services/read-from-disk.service'); - describe('Dependency sagas', () => { + const projectId = 'foo'; + const projectPath = '/path/to/project'; + describe('addDependency saga', () => { - const startAction = { - projectId: 'foo', + const action = { + projectId, dependencyName: 'redux', version: '2.3', }; @@ -36,121 +54,377 @@ describe('Dependency sagas', () => { version: '2.3', }; - it('should install new dependency', () => { - const saga = addDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(installDependency, '/path/to/project/', 'redux', '2.3') + let saga; + beforeEach(() => { + saga = handleAddDependency(action); + }); + + it('should immediately install on empty queue', () => { + const queuedAction = null; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(queuedAction).value).toEqual( + put( + queueDependencyInstall(projectId, dependency.name, dependency.version) + ) ); expect(saga.next().value).toEqual( - call(loadProjectDependency, '/path/to/project/', 'redux') + put( + installDependencyStart(projectId, dependency.name, dependency.version) + ) ); - expect(saga.next(dependency).value).toEqual( - put(addDependencyFinish('foo', dependency)) + expect(saga.next().done).toBe(true); + }); + + it('should queue install on non-empty queue', () => { + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(queuedAction).value).toEqual( + put( + queueDependencyInstall(projectId, dependency.name, dependency.version) + ) ); expect(saga.next().done).toBe(true); }); + }); - it('should handle error', () => { - const error = new Error('something wrong'); - const saga = addDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(installDependency, '/path/to/project/', 'redux', '2.3') + describe('updateDependency saga', () => { + const action = { + projectId, + dependencyName: 'redux', + latestVersion: '2.3', + }; + const dependency = { + name: 'redux', + version: '2.3', + updating: true, + }; + + let saga; + beforeEach(() => { + saga = handleUpdateDependency(action); + }); + + it('should immediately install on empty queue', () => { + const queuedAction = false; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) ); - expect(saga.throw(error).value).toEqual( - call([console, 'error'], 'Failed to install dependency', error) + expect(saga.next(queuedAction).value).toEqual( + put( + queueDependencyInstall( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) ); expect(saga.next().value).toEqual( - put(addDependencyError('foo', 'redux')) + put( + installDependencyStart( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) + ); + expect(saga.next().done).toBe(true); + }); + + it('should queue install on non-empty queue', () => { + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(queuedAction).value).toEqual( + put( + queueDependencyInstall( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) ); expect(saga.next().done).toBe(true); }); }); - describe('updateDependency saga', () => { - const startAction = { - projectId: 'foo', + describe('deleteDependency saga', () => { + const action = { + projectId, dependencyName: 'redux', - latestVersion: '2.5', + version: '2.3', + }; + const dependency = { + name: 'redux', }; - it('should update dependency', () => { - const saga = updateDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(installDependency, '/path/to/project/', 'redux', '2.5') + let saga; + beforeEach(() => { + saga = handleDeleteDependency(action); + }); + + it('should immediately uninstall on empty queue', () => { + const queuedAction = null; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(queuedAction).value).toEqual( + put(queueDependencyUninstall(projectId, dependency.name)) ); expect(saga.next().value).toEqual( - put(updateDependencyFinish('foo', 'redux', '2.5')) + put(uninstallDependencyStart(projectId, dependency.name)) ); expect(saga.next().done).toBe(true); }); - it('should handle error', () => { - const error = new Error('some error'); - const saga = updateDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(installDependency, '/path/to/project/', 'redux', '2.5') + it('should queue uninstall on non-empty queue', () => { + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(queuedAction).value).toEqual( + put(queueDependencyUninstall(projectId, dependency.name)) + ); + expect(saga.next().done).toBe(true); + }); + }); + + describe('startInstallingDependencies saga', () => { + const action = { + projectId, + dependencies: [ + { name: 'redux', version: '3.3' }, + { name: 'react-redux', version: '3.0', updating: true }, + ], + }; + + let saga; + beforeEach(() => { + saga = handleInstallDependenciesStart(action); + }); + + it('should install dependencies', () => { + const storedDependencies = [ + { + name: 'redux', + version: '3.3', + location: 'dependencies', + description: 'foo', + }, + { + name: 'react-redux', + version: '3.0', + location: 'dependencies', + description: 'bar', + }, + ]; + + expect(saga.next().value).toEqual(select(getPathForProjectId, projectId)); + expect(saga.next(projectPath).value).toEqual( + call(installDependencies, projectPath, action.dependencies) + ); + expect(saga.next().value).toEqual( + call(loadProjectDependencies, projectPath, action.dependencies) + ); + expect(saga.next(storedDependencies).value).toEqual( + put(installDependenciesFinish(projectId, storedDependencies)) ); + expect(saga.next().done).toBe(true); + }); + + it('should handle error', () => { + const error = new Error('oops'); + + expect(saga.next().value).toEqual(select(getPathForProjectId, projectId)); + saga.next(projectPath); expect(saga.throw(error).value).toEqual( - call([console, 'error'], 'Failed to update dependency', error) + call([console, console.error], 'Failed to install dependencies', error) ); expect(saga.next().value).toEqual( - put(updateDependencyError('foo', 'redux')) + put(installDependenciesError(projectId, action.dependencies)) ); expect(saga.next().done).toBe(true); }); }); - describe('deleteDependency saga', () => { - const startAction = { - projectId: 'foo', - dependencyName: 'redux', + describe('startUninstallingDependencies saga', () => { + const action = { + projectId, + dependencies: [{ name: 'redux' }, { name: 'react-redux' }], }; - it('should delete demendency', () => { - const saga = deleteDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(uninstallDependency, '/path/to/project/', 'redux') + let saga; + beforeEach(() => { + saga = handleUninstallDependenciesStart(action); + }); + + it('should uninstall dependencies', () => { + expect(saga.next().value).toEqual(select(getPathForProjectId, projectId)); + expect(saga.next(projectPath).value).toEqual( + call(uninstallDependencies, projectPath, action.dependencies) ); expect(saga.next().value).toEqual( - put(deleteDependencyFinish('foo', 'redux')) + put(uninstallDependenciesFinish(projectId, action.dependencies)) ); expect(saga.next().done).toBe(true); }); it('should handle error', () => { - const error = new Error('some error'); - const saga = deleteDependency(startAction); - expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo')); - expect(saga.next('/path/to/project/').value).toEqual( - call(uninstallDependency, '/path/to/project/', 'redux') - ); + const error = new Error('oops'); + + expect(saga.next().value).toEqual(select(getPathForProjectId, projectId)); + saga.next(projectPath); expect(saga.throw(error).value).toEqual( - call([console, 'error'], 'Failed to delete dependency', error) + call( + [console, console.error], + 'Failed to uninstall dependencies', + error + ) ); expect(saga.next().value).toEqual( - put(deleteDependencyError('foo', 'redux')) + put(uninstallDependenciesError(projectId, action.dependencies)) ); expect(saga.next().done).toBe(true); }); }); - describe('dependencies root saga', () => { - it('should watching for start actions', () => { + describe('handleQueueActionCompleted saga', () => { + it(`should dispatch ${START_NEXT_ACTION_IN_QUEUE} when next queue action exists`, () => { + const saga = handleQueueActionCompleted({ projectId }); + const nextAction = { + action: 'install', + active: false, + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(nextAction).value).toEqual( + put(startNextActionInQueue(projectId)) + ); + expect(saga.next().done).toBe(true); + }); + + it(`should dispatch ${START_NEXT_ACTION_IN_QUEUE} when queue is empty`, () => { + const saga = handleQueueActionCompleted({ projectId }); + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next().done).toBe(true); + }); + }); + + describe('handleNextActionInQueue saga', () => { + let saga; + beforeEach(() => { + saga = handleStartNextActionInQueue({ projectId }); + }); + + it('should do nothing if the queue is empty', () => { + const consoleErrorOriginal = global.console.error; + global.console.error = jest.fn(); + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + saga.next(); + expect(console.error).toBeCalled(); + expect(saga.next().done).toBe(true); + + global.console.error = consoleErrorOriginal; + }); + + it(`should dispatch ${INSTALL_DEPENDENCIES_START} if an install action is queued`, () => { + const nextAction = { + action: 'install', + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(nextAction).value).toEqual( + put(installDependenciesStart(projectId, nextAction.dependencies)) + ); + }); + + it(`should dispatch ${UNINSTALL_DEPENDENCIES_START} if an uninstall action is queued`, () => { + const nextAction = { + action: 'uninstall', + dependencies: [{ name: 'redux' }], + }; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(nextAction).value).toEqual( + put(uninstallDependenciesStart(projectId, nextAction.dependencies)) + ); + }); + }); + + describe('root saga', () => { + it('should start watching for actions', () => { const saga = rootSaga(); expect(saga.next().value).toEqual( - takeEvery(ADD_DEPENDENCY_START, addDependency) + takeEvery(ADD_DEPENDENCY, handleAddDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(UPDATE_DEPENDENCY, handleUpdateDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(DELETE_DEPENDENCY, handleDeleteDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(INSTALL_DEPENDENCIES_START, handleInstallDependenciesStart) + ); + expect(saga.next().value).toEqual( + takeEvery( + UNINSTALL_DEPENDENCIES_START, + handleUninstallDependenciesStart + ) ); expect(saga.next().value).toEqual( - takeEvery(UPDATE_DEPENDENCY_START, updateDependency) + takeEvery( + [ + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, + ], + handleQueueActionCompleted + ) ); expect(saga.next().value).toEqual( - takeEvery(DELETE_DEPENDENCY_START, deleteDependency) + takeEvery(START_NEXT_ACTION_IN_QUEUE, handleStartNextActionInQueue) ); expect(saga.next().done).toBe(true); }); diff --git a/src/services/dependencies.service.js b/src/services/dependencies.service.js index 85275a1d..383b2083 100644 --- a/src/services/dependencies.service.js +++ b/src/services/dependencies.service.js @@ -2,33 +2,60 @@ import { PACKAGE_MANAGER_CMD } from './platform.service'; import * as childProcess from 'child_process'; -const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => +import type { QueuedDependency } from '../types'; + +const spawnProcess = ( + cmd: string, + cmdArgs: string[], + projectPath: string +): Promise => new Promise((resolve, reject) => { + const output = { + stdout: '', + stderr: '', + }; const child = childProcess.spawn(cmd, cmdArgs, { cwd: projectPath, }); + + child.stdout.on('data', data => (output.stdout += data.toString())); + child.stderr.on('data', data => (output.stderr += data.toString())); child.on( 'exit', - code => (code ? reject(child.stderr) : resolve(child.stdout)) + code => (code ? reject(output.stderr) : resolve(output.stdout)) ); // logger(child) // service will be used here later }); -export const installDependency = ( +export const getDependencyInstallationCommand = ( + dependencies: Array +): Array => { + const versionedDependencies = dependencies.map( + ({ name, version }) => name + (version ? `@${version}` : '') + ); + + return ['add', ...versionedDependencies, '-SE']; +}; + +export const installDependencies = ( projectPath: string, - dependencyName: string, - version: string + dependencies: Array ) => spawnProcess( PACKAGE_MANAGER_CMD, - ['add', `${dependencyName}@${version}`, '-SE'], + getDependencyInstallationCommand(dependencies), projectPath ); -export const uninstallDependency = ( +export const uninstallDependencies = ( projectPath: string, - dependencyName: string -) => spawnProcess(PACKAGE_MANAGER_CMD, ['remove', dependencyName], projectPath); + dependencies: Array +) => + spawnProcess( + PACKAGE_MANAGER_CMD, + ['remove', ...dependencies.map(({ name }) => name)], + projectPath + ); export const reinstallDependencies = (projectPath: string) => spawnProcess(PACKAGE_MANAGER_CMD, ['install'], projectPath); diff --git a/src/services/read-from-disk.service.js b/src/services/read-from-disk.service.js index 302e172f..1526a07b 100644 --- a/src/services/read-from-disk.service.js +++ b/src/services/read-from-disk.service.js @@ -5,7 +5,7 @@ import * as path from 'path'; import { pick } from '../utils'; -import type { DependencyLocation, ProjectInternal } from '../types'; +import type { QueuedDependency, DependencyLocation } from '../types'; /** * Load a project's package.json @@ -163,6 +163,36 @@ export function loadProjectDependency( }); } +/** + * Wrapper around `loadProjectDependency` that fetches all dependencies from + * an array. + */ +export function loadProjectDependencies( + projectPath: string, + dependencies: Array +) { + return new Promise((resolve, reject) => { + asyncMap( + dependencies, + function({ name, location }, callback) { + loadProjectDependency(projectPath, name, location) + .then(dependency => callback(null, dependency)) + .catch(callback); + }, + (err, results) => { + if (err) { + return reject(err); + } + + // Filter out any unloaded dependencies + const filteredResults = results.filter(result => result); + + resolve(filteredResults); + } + ); + }); +} + /** * Wrapper around `loadProjectDependency` that fetches all dependencies for * a specific project. @@ -171,10 +201,7 @@ export function loadProjectDependency( * dependencies... might need to set up a streaming service that can communicate * loading status if it takes more than a few hundred ms. */ -export function loadAllProjectDependencies( - project: ProjectInternal, - projectPath: string -) { +export function loadAllProjectDependencies(projectPath: string) { // Get a fresh copy of the dependencies from the project's package.json return loadPackageJson(projectPath).then( packageJson => @@ -183,39 +210,17 @@ export function loadAllProjectDependencies( // We can reasonably assume all projects have dependencies // but some may not have devDependencies const deps = Object.keys(packageJson.dependencies); - const devDeps = Object.keys(packageJson.devDependencies || []); - const dependencyNames = [...deps, ...devDeps]; - - // Each project in a Guppy directory should have a package.json. - // We'll read all the project info we need from this file. - asyncMap( - dependencyNames, - function(dependencyName, callback) { - // If the name of the package is present in the devDeps array - // then its location is devDependencies - const dependencyLocation = devDeps.includes(dependencyName) - ? 'devDependencies' - : 'dependencies'; - - loadProjectDependency( - projectPath, - dependencyName, - dependencyLocation - ) - .then(dependency => callback(null, dependency)) - .catch(callback); - }, - (err, results) => { - if (err) { - return reject(err); - } - - // Filter out any unloaded dependencies - const filteredResults = results.filter(result => result); - + const devDeps = Object.keys(packageJson.devDependencies || {}); + const dependencies = [...deps, ...devDeps].map(name => ({ + name, + location: devDeps.includes(name) ? 'devDependencies' : 'dependencies', + })); + + loadProjectDependencies(projectPath, dependencies).then( + dependenciesFromPackageJson => { // The results will be an array of package.jsons. // I want a database-style map. - const dependencies = filteredResults.reduce( + const dependenciesByName = dependenciesFromPackageJson.reduce( (dependenciesMap, dependency) => ({ ...dependenciesMap, [dependency.name]: dependency, @@ -223,7 +228,7 @@ export function loadAllProjectDependencies( {} ); - resolve(dependencies); + resolve(dependenciesByName); } ); }) diff --git a/src/store/index.js b/src/store/index.js index 004a60ac..4d3a23ba 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -28,9 +28,9 @@ export default function configureStore() { // Batch updates, so that frequent dispatches don't cause performance issues engine = debounce(engine, 1000); - // We don't want to store task info. - // Tasks - engine = filter(engine, null, ['appLoaded', 'tasks']); + // We don't want to store ephemeral info, such as application + // status, tasks, or the dependency queue. + engine = filter(engine, null, ['appLoaded', 'tasks', 'queue']); const storageMiddleware = storage.createMiddleware(engine); const wrappedReducer = storage.reducer(rootReducer); diff --git a/src/types.js b/src/types.js index d90aa5de..6e5bddd6 100644 --- a/src/types.js +++ b/src/types.js @@ -16,8 +16,21 @@ export type Log = { export type TaskType = 'short-term' | 'sustained'; export type TaskStatus = 'idle' | 'pending' | 'success' | 'failed'; -export type DependencyStatus = 'idle' | 'installing' | 'updating' | 'deleting'; +export type DependencyStatus = + | 'idle' + | 'queued-install' + | 'queued-update' + | 'queued-delete' + | 'installing' + | 'updating' + | 'deleting'; export type DependencyLocation = 'dependencies' | 'devDependencies'; +export type QueueAction = 'install' | 'uninstall'; +export type QueuedDependency = { + name: string, + version?: string, + updating?: boolean, +}; export type Task = { id: string,