From 504de22f758b3149e12cb429d6013f63003a4f1b Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Thu, 30 Aug 2018 23:47:23 -0400 Subject: [PATCH 1/7] update redux action structure - update dependencies reducer and tests - add queue reducer and tests - move snapshots to separate directory - add `queued-` statuses --- src/actions/index.js | 127 +++--- .../dependencies.reducer.test.js.snap | 233 +++++++++++ .../__snapshots__/queue.reducer.test.js.snap | 141 +++++++ src/reducers/dependencies.reducer.js | 92 +++-- src/reducers/dependencies.reducer.test.js | 373 ++++++++---------- src/reducers/index.js | 4 +- src/reducers/package-json-locked.reducer.js | 92 ----- src/reducers/queue.reducer.js | 119 ++++++ src/reducers/queue.reducer.test.js | 162 ++++++++ src/sagas/dependency.saga.js | 15 - src/types.js | 10 +- 11 files changed, 955 insertions(+), 413 deletions(-) create mode 100644 src/reducers/__snapshots__/dependencies.reducer.test.js.snap create mode 100644 src/reducers/__snapshots__/queue.reducer.test.js.snap 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..5651e889 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -31,15 +31,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'; @@ -182,91 +185,115 @@ export const clearConsole = (task: Task) => ({ task, }); -export const deleteDependencyStart = ( - projectId: string, - dependencyName: string -) => ({ - type: DELETE_DEPENDENCY_START, +export const addDependency = (projectId: string, dependencyName: string) => ({ + type: ADD_DEPENDENCY, projectId, dependencyName, }); -export const deleteDependencyError = ( +export const updateDependency = ( projectId: string, - dependencyName: string + dependencyName: string, + latestVersion: string ) => ({ - type: DELETE_DEPENDENCY_ERROR, + type: UPDATE_DEPENDENCY, projectId, dependencyName, + latestVersion, }); -export const deleteDependencyFinish = ( +export const deleteDependency = ( projectId: string, dependencyName: string ) => ({ - type: DELETE_DEPENDENCY_FINISH, + type: DELETE_DEPENDENCY, projectId, dependencyName, }); -export const updateDependencyStart = ( +export const installDependenciesStart = ( projectId: string, - dependencyName: string, - latestVersion: string + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_START, - projectId, - dependencyName, - latestVersion, + type: INSTALL_DEPENDENCIES_START, + dependencies, }); -export const updateDependencyError = ( +export const installDependencyStart = ( projectId: string, - dependencyName: string + name: string, + version: string +) => installDependenciesStart(projectId, [{ name, version }]); + +export const installDependenciesError = ( + projectId: string, + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_ERROR, + type: INSTALL_DEPENDENCIES_ERROR, projectId, - dependencyName, + dependencies, }); -export const updateDependencyFinish = ( +export const installDependenciesFinish = ( projectId: string, - dependencyName: string, - latestVersion: string + dependencies: Array ) => ({ - type: UPDATE_DEPENDENCY_FINISH, + type: INSTALL_DEPENDENCIES_FINISH, projectId, - dependencyName, - latestVersion, + dependencies, }); -export const addDependencyStart = ( +export const uninstallDependenciesStart = ( projectId: string, - dependencyName: string, - version: string + dependencies: Array +) => ({ + type: UNINSTALL_DEPENDENCIES_START, + dependencies, +}); + +export const uninstallDependencyStart = (projectId: string, name: string) => + uninstallDependenciesStart(projectId, [{ name }]); + +export const uninstallDependenciesError = ( + projectId: 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/reducers/__snapshots__/dependencies.reducer.test.js.snap b/src/reducers/__snapshots__/dependencies.reducer.test.js.snap new file mode 100644 index 00000000..6b7b8e7b --- /dev/null +++ b/src/reducers/__snapshots__/dependencies.reducer.test.js.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dependencies reducer should handle ADD_DEPENDENCY 1`] = ` +Object { + "foo": Object { + "redux": Object { + "description": "", + "homepage": "", + "license": "", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "", + "url": "", + }, + "status": "queued-install", + "version": "", + }, + }, +} +`; + +exports[`dependencies reducer should handle DELETE_DEPENDENCY 1`] = ` +Object { + "foo": 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-delete", + "version": "3.2", + }, + }, +} +`; + +exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_ERROR 1`] = ` +Object { + "foo": 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": "idle", + "version": "3.2", + }, + }, +} +`; + +exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_FINISH 1`] = ` +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", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "idle", + "version": "3.3", + }, + }, +} +`; + +exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_START 1`] = ` +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", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "updating", + "version": "3.2", + }, + }, +} +`; + +exports[`dependencies reducer should handle LOAD_DEPENDENCY_INFO_FROM_DISK 1`] = ` +Object { + "baz": Object {}, + "foo": Object { + "redux": Object {}, + }, +} +`; + +exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_ERROR 1`] = ` +Object { + "foo": 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": "idle", + "version": "3.2", + }, + }, +} +`; + +exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_FINISH 1`] = ` +Object { + "foo": Object {}, +} +`; + +exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_START 1`] = ` +Object { + "foo": Object { + "redux": Object { + "description": "dependency description", + "homepage": "https://dependency-homepage.io", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "deleting", + "version": "3.2", + }, + }, +} +`; + +exports[`dependencies reducer should handle UPDATE_DEPENDENCY 1`] = ` +Object { + "foo": 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", + }, + }, +} +`; diff --git a/src/reducers/__snapshots__/queue.reducer.test.js.snap b/src/reducers/__snapshots__/queue.reducer.test.js.snap new file mode 100644 index 00000000..42679ddf --- /dev/null +++ b/src/reducers/__snapshots__/queue.reducer.test.js.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for mixed existing queue 1`] = ` +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", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for new dependency on empty queue 1`] = ` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "redux", + "updating": undefined, + "version": "3.2", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for new dependency on existing queue 1`] = ` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + Object { + "name": "redux", + "updating": undefined, + "version": undefined, + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for updating dependency 1`] = ` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "redux", + "updating": true, + "version": "3.3", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle QUEUE_DEPENDENCY_UNINSTALL 1`] = ` +Object { + "foo": Array [ + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "redux", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle QUEUE_DEPENDENCY_UNINSTALL for mixed existing queue 1`] = ` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + ], + }, + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "redux", + }, + Object { + "name": "lodash", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle START_NEXT_ACTION_IN_QUEUE for queue with next action 1`] = ` +Object { + "foo": Array [ + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + ], + }, + ], +} +`; + +exports[`queue reducer should handle START_NEXT_ACTION_IN_QUEUE for queue with no more actions 1`] = `Object {}`; diff --git a/src/reducers/dependencies.reducer.js b/src/reducers/dependencies.reducer.js index 8a0d76de..bd6ab848 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,89 @@ 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] = { + ...draftState[projectId][dependency.name], + ...dependency, + 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..33939002 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', () => { @@ -28,365 +28,304 @@ describe('dependencies reducer', () => { dependencies: { redux: {} }, }; - expect(reducer(prevState, action)).toMatchInlineSnapshot(` -Object { - "baz": Object {}, - "foo": Object { - "redux": Object {}, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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', }; - expect(reducer(prevState, action)).toMatchInlineSnapshot(` -Object { - "foo": Object { - "redux": Object { - "description": "", - "homepage": "", - "license": "", - "location": "dependencies", - "name": "redux", - "repository": Object { - "type": "", - "url": "", - }, - "status": "installing", - "version": "", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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 {}, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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(` -Object { - "foo": Object { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "idle", - "version": "3.2", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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 { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "updating", - "version": "3.2", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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(` -Object { - "foo": Object { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "idle", - "version": "3.2", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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', + version: '3.3', + }, + { + name: 'react-redux', + 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 { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "updating", - "version": "4.0", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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(` -Object { - "foo": Object { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "deleting", - "version": "3.2", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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(` -Object { - "foo": Object { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": "https://github.com", - "status": "idle", - "version": "3.2", - }, - }, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); - 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(` -Object { - "foo": Object {}, -} -`); + expect(reducer(prevState, action)).toMatchSnapshot(); }); }); 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/queue.reducer.js b/src/reducers/queue.reducer.js new file mode 100644 index 00000000..2778eef1 --- /dev/null +++ b/src/reducers/queue.reducer.js @@ -0,0 +1,119 @@ +// @flow +import produce from 'immer'; +import { + QUEUE_DEPENDENCY_INSTALL, + QUEUE_DEPENDENCY_UNINSTALL, + START_NEXT_ACTION_IN_QUEUE, +} from '../actions'; + +import type { Action } from 'redux'; +import type { Dependency, QueueAction } from '../actions'; + +// Installing a new dependency and updating an existing one +// both use the same yarn command (yarn add) and as such can +// be bulked together into a single install. However, we want +// to display either 'Installing...' or 'Updating...', respectively, +// so we have to track the install's purpose per dependency in +// the install queue +type QueuedDependency = Dependency & { updating?: boolean }; + +type State = { + [projectId: string]: Array<{ + action: QueueAction, + dependencies: Array, + }>, +}; + +const initialState = {}; + +export default (state: State = initialState, action: Action) => { + switch (action.type) { + case START_NEXT_ACTION_IN_QUEUE: { + 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]; + } + }); + } + + 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'); + if (!installQueue) { + installQueue = { + action: 'install', + 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'); + if (!installQueue) { + installQueue = { + action: 'uninstall', + dependencies: [], + }; + projectQueue.push(installQueue); + } + + // add dependency to the uninstall queue + installQueue.dependencies.push({ + name, + }); + + // update the project's uninstall queue + draftState[projectId] = projectQueue; + }); + } + + default: + return state; + } +}; + +// +// +// +// Selectors +export const getPackageJsonLockedForProjectId = ( + state: any, + projectId: string +) => !!state.queue[projectId]; diff --git a/src/reducers/queue.reducer.test.js b/src/reducers/queue.reducer.test.js new file mode 100644 index 00000000..50b7a44f --- /dev/null +++ b/src/reducers/queue.reducer.test.js @@ -0,0 +1,162 @@ +import reducer, { getPackageJsonLockedForProjectId } from './queue.reducer'; +import { + QUEUE_DEPENDENCY_INSTALL, + QUEUE_DEPENDENCY_UNINSTALL, + START_NEXT_ACTION_IN_QUEUE, +} from '../actions'; + +describe('queue reducer', () => { + it('should return initial state', () => { + expect(reducer(undefined, {})).toEqual({}); + }); + + it(`should handle ${START_NEXT_ACTION_IN_QUEUE} for queue with next action`, () => { + const prevState = { + foo: [ + { action: 'install', dependencies: [{ name: 'redux' }] }, + { action: 'uninstall', dependencies: [{ name: 'react-redux' }] }, + ], + }; + + const action = { + type: START_NEXT_ACTION_IN_QUEUE, + projectId: 'foo', + }; + + expect(reducer(prevState, action)).toMatchSnapshot(); + }); + + it(`should handle ${START_NEXT_ACTION_IN_QUEUE} for queue with no more actions`, () => { + const prevState = { + foo: [{ action: 'install', dependencies: [{ name: 'redux' }] }], + }; + + const action = { + type: START_NEXT_ACTION_IN_QUEUE, + projectId: 'foo', + }; + + expect(reducer(prevState, action)).toMatchSnapshot(); + }); + + 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)).toMatchSnapshot(); + }); + + 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)).toMatchSnapshot(); + }); + + 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)).toMatchSnapshot(); + }); + + it(`should handle ${QUEUE_DEPENDENCY_UNINSTALL}`, () => { + const prevState = {}; + + const action = { + type: QUEUE_DEPENDENCY_UNINSTALL, + projectId: 'foo', + name: 'redux', + }; + + expect(reducer(prevState, action)).toMatchSnapshot(); + }); + + 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)).toMatchSnapshot(); + }); + + 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)).toMatchSnapshot(); + }); + + describe('getPackageJsonLockedForProjectId', () => { + it('should return false when no dependencies are queued', () => { + const state = { + queue: {}, + }; + + const projectId = 'foo'; + + expect(getPackageJsonLockedForProjectId(state, projectId)).toBe(false); + }); + + it('should return true when dependencies are queued', () => { + const state = { + queue: { + foo: [ + { + action: 'install', + dependencies: [{ name: 'redux' }], + }, + ], + }, + }; + + const projectId = 'foo'; + + expect(getPackageJsonLockedForProjectId(state, projectId)).toBe(true); + }); + }); +}); diff --git a/src/sagas/dependency.saga.js b/src/sagas/dependency.saga.js index f02f9223..eea5e6bd 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -21,10 +21,6 @@ import { 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({ projectId, dependencyName, @@ -45,10 +41,6 @@ export function* addDependency({ } } -/** - * Trying to update existing dependency, if success dispatching "finish" action, - * if not - dispatching "error" action - */ export function* updateDependency({ projectId, dependencyName, @@ -64,10 +56,6 @@ export function* updateDependency({ } } -/** - * Trying to delete dependency, if success dispatching "finish" action, - * if not - dispatching "error" action - */ export function* deleteDependency({ projectId, dependencyName, @@ -82,9 +70,6 @@ export function* deleteDependency({ } } -/** - * Root dependencies saga, watching for "start" actions - */ export default function* rootSaga(): Saga { yield takeEvery(ADD_DEPENDENCY_START, addDependency); yield takeEvery(UPDATE_DEPENDENCY_START, updateDependency); diff --git a/src/types.js b/src/types.js index d90aa5de..fe34fc50 100644 --- a/src/types.js +++ b/src/types.js @@ -16,8 +16,16 @@ 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 Task = { id: string, From e7c4d7e13c0f41f2141c99f756cada3eecbd81f2 Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Fri, 31 Aug 2018 03:03:26 -0400 Subject: [PATCH 2/7] update dependency saga for queued actions - update depedency saga tests - add loadProjectDependencies to read-from-disk.service --- .../__snapshots__/queue.reducer.test.js.snap | 11 + src/reducers/queue.reducer.js | 3 + src/reducers/queue.reducer.test.js | 29 +- src/sagas/dependency.saga.js | 174 +++++++-- src/sagas/dependency.saga.test.js | 351 ++++++++++++++---- src/services/dependencies.service.js | 26 +- src/services/read-from-disk.service.js | 95 ++--- 7 files changed, 523 insertions(+), 166 deletions(-) diff --git a/src/reducers/__snapshots__/queue.reducer.test.js.snap b/src/reducers/__snapshots__/queue.reducer.test.js.snap index 42679ddf..c417687d 100644 --- a/src/reducers/__snapshots__/queue.reducer.test.js.snap +++ b/src/reducers/__snapshots__/queue.reducer.test.js.snap @@ -1,5 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`queue reducer getNextActionForProjectId should return next action when one is present 1`] = ` +Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "redux", + }, + ], +} +`; + exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for mixed existing queue 1`] = ` Object { "foo": Array [ diff --git a/src/reducers/queue.reducer.js b/src/reducers/queue.reducer.js index 2778eef1..a7e03b78 100644 --- a/src/reducers/queue.reducer.js +++ b/src/reducers/queue.reducer.js @@ -117,3 +117,6 @@ export const getPackageJsonLockedForProjectId = ( state: any, projectId: string ) => !!state.queue[projectId]; + +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 index 50b7a44f..5355c016 100644 --- a/src/reducers/queue.reducer.test.js +++ b/src/reducers/queue.reducer.test.js @@ -1,4 +1,7 @@ -import reducer, { getPackageJsonLockedForProjectId } from './queue.reducer'; +import reducer, { + getPackageJsonLockedForProjectId, + getNextActionForProjectId, +} from './queue.reducer'; import { QUEUE_DEPENDENCY_INSTALL, QUEUE_DEPENDENCY_UNINSTALL, @@ -159,4 +162,28 @@ describe('queue reducer', () => { expect(getPackageJsonLockedForProjectId(state, projectId)).toBe(true); }); }); + + 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)).toMatchSnapshot(); + }); + + 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 eea5e6bd..98415f43 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -2,20 +2,36 @@ import { select, call, put, takeEvery } from 'redux-saga/effects'; import { getPathForProjectId } from '../reducers/paths.reducer'; import { - installDependency, - uninstallDependency, + getPackageJsonLockedForProjectId, + getNextActionForProjectId, +} from '../reducers/queue.reducer'; +import { + 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'; @@ -26,18 +42,19 @@ export function* addDependency({ 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 packageJsonLocked = yield select( + getPackageJsonLockedForProjectId, + projectId + ); + const dependency = { name: dependencyName, version }; + + // if there are ongoing actions, queue this dependency + if (packageJsonLocked) { + yield put(queueDependencyInstall(projectId, dependency)); + } else { + // if the queue for this project is empty, go ahead and install + // the dependency + yield put(installDependencyStart(projectId, dependency)); } } @@ -45,33 +62,116 @@ export function* updateDependency({ projectId, dependencyName, latestVersion, +}: Action): Saga { + const packageJsonLocked = yield select( + getPackageJsonLockedForProjectId, + projectId + ); + const dependency = { + name: dependencyName, + version: latestVersion, + updating: true, + }; + + if (packageJsonLocked) { + yield put(queueDependencyInstall(projectId, dependency)); + } else { + yield put(installDependencyStart(projectId, dependency)); + } +} + +export function* deleteDependency({ + projectId, + dependencyName, +}: Action): Saga { + const packageJsonLocked = yield select( + getPackageJsonLockedForProjectId, + projectId + ); + const dependency = { name: dependencyName }; + + if (packageJsonLocked) { + yield put(queueDependencyUninstall(projectId, dependency)); + } else { + yield put(uninstallDependencyStart(projectId, dependency)); + } +} + +export function* startInstallingDependencies({ + 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)); } } -export function* deleteDependency({ +export function* startUninstallingDependencies({ 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 { + yield put(startNextActionInQueue(projectId)); +} + +export function* handleNextActionInQueue({ projectId }: Action): Saga { + const nextAction = yield select(getNextActionForProjectId, projectId); + + // if the queue is empty, take no further action + if (!nextAction) return; + + const actionCreator = + nextAction.action === 'install' + ? installDependenciesStart + : uninstallDependenciesStart; + yield put(actionCreator(projectId, nextAction.dependencies)); +} + +// 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, addDependency); + yield takeEvery(UPDATE_DEPENDENCY, updateDependency); + yield takeEvery(DELETE_DEPENDENCY, deleteDependency); + yield takeEvery(INSTALL_DEPENDENCIES_START, startInstallingDependencies); + yield takeEvery(UNINSTALL_DEPENDENCIES_START, startUninstallingDependencies); + yield takeEvery( + [ + INSTALL_DEPENDENCIES_ERROR, + INSTALL_DEPENDENCIES_FINISH, + UNINSTALL_DEPENDENCIES_ERROR, + UNINSTALL_DEPENDENCIES_FINISH, + ], + handleQueueActionCompleted + ); + yield takeEvery(START_NEXT_ACTION_IN_QUEUE, handleNextActionInQueue); } diff --git a/src/sagas/dependency.saga.test.js b/src/sagas/dependency.saga.test.js index ba936247..f377b2c4 100644 --- a/src/sagas/dependency.saga.test.js +++ b/src/sagas/dependency.saga.test.js @@ -3,31 +3,52 @@ import rootSaga, { addDependency, updateDependency, deleteDependency, + startInstallingDependencies, + startUninstallingDependencies, + handleQueueActionCompleted, + handleNextActionInQueue, } from './dependency.saga'; import { getPathForProjectId } from '../reducers/paths.reducer'; import { - installDependency, - uninstallDependency, + getPackageJsonLockedForProjectId, + getNextActionForProjectId, +} from '../reducers/queue.reducer'; +import { + 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 +57,303 @@ 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 = addDependency(action); + }); + + it('should immediately install on empty queue', () => { + const packageJsonLocked = false; + + expect(saga.next().value).toEqual( + select(getPackageJsonLockedForProjectId, projectId) ); + expect(saga.next(packageJsonLocked).value).toEqual( + put(installDependencyStart(projectId, dependency)) + ); + expect(saga.next().done).toBe(true); + }); + + it('should queue install on non-empty queue', () => { + const packageJsonLocked = true; + expect(saga.next().value).toEqual( - call(loadProjectDependency, '/path/to/project/', 'redux') + select(getPackageJsonLockedForProjectId, projectId) ); - expect(saga.next(dependency).value).toEqual( - put(addDependencyFinish('foo', dependency)) + expect(saga.next(packageJsonLocked).value).toEqual( + put(queueDependencyInstall(projectId, dependency)) ); expect(saga.next().done).toBe(true); }); + }); + + describe('updateDependency saga', () => { + const action = { + projectId, + dependencyName: 'redux', + latestVersion: '2.3', + }; + const dependency = { + name: 'redux', + version: '2.3', + updating: true, + }; + + let saga; + beforeEach(() => { + saga = updateDependency(action); + }); - 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') + it('should immediately install on empty queue', () => { + const packageJsonLocked = false; + + expect(saga.next().value).toEqual( + select(getPackageJsonLockedForProjectId, projectId) ); - expect(saga.throw(error).value).toEqual( - call([console, 'error'], 'Failed to install dependency', error) + expect(saga.next(packageJsonLocked).value).toEqual( + put(installDependencyStart(projectId, dependency)) ); + expect(saga.next().done).toBe(true); + }); + + it('should queue install on non-empty queue', () => { + const packageJsonLocked = true; + expect(saga.next().value).toEqual( - put(addDependencyError('foo', 'redux')) + select(getPackageJsonLockedForProjectId, projectId) + ); + expect(saga.next(packageJsonLocked).value).toEqual( + put(queueDependencyInstall(projectId, dependency)) ); 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 = deleteDependency(action); + }); + + it('should immediately uninstall on empty queue', () => { + const packageJsonLocked = false; + + expect(saga.next().value).toEqual( + select(getPackageJsonLockedForProjectId, projectId) + ); + expect(saga.next(packageJsonLocked).value).toEqual( + put(uninstallDependencyStart(projectId, dependency)) ); + expect(saga.next().done).toBe(true); + }); + + it('should queue uninstall on non-empty queue', () => { + const packageJsonLocked = true; + expect(saga.next().value).toEqual( - put(updateDependencyFinish('foo', 'redux', '2.5')) + select(getPackageJsonLockedForProjectId, projectId) + ); + expect(saga.next(packageJsonLocked).value).toEqual( + put(queueDependencyUninstall(projectId, dependency)) ); 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') + describe('startInstallingDependencies saga', () => { + const action = { + projectId, + dependencies: [ + { name: 'redux', version: '3.3' }, + { name: 'react-redux', version: '3.0', updating: true }, + ], + }; + + let saga; + beforeEach(() => { + saga = startInstallingDependencies(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 = startUninstallingDependencies(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}`, () => { + const saga = handleQueueActionCompleted({ projectId }); + + expect(saga.next().value).toEqual(put(startNextActionInQueue(projectId))); + expect(saga.next().done).toBe(true); + }); + }); + + describe('handleNextActionInQueue saga', () => { + let saga; + beforeEach(() => { + saga = handleNextActionInQueue({ projectId }); + }); + + it('should do nothing if the queue is empty', () => { + const nextAction = null; + + expect(saga.next().value).toEqual( + select(getNextActionForProjectId, projectId) + ); + expect(saga.next(nextAction).value).toEqual(undefined); + expect(saga.next().done).toBe(true); + }); + + 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, addDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(UPDATE_DEPENDENCY, updateDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(DELETE_DEPENDENCY, deleteDependency) + ); + expect(saga.next().value).toEqual( + takeEvery(INSTALL_DEPENDENCIES_START, startInstallingDependencies) + ); + expect(saga.next().value).toEqual( + takeEvery(UNINSTALL_DEPENDENCIES_START, startUninstallingDependencies) ); 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, handleNextActionInQueue) ); expect(saga.next().done).toBe(true); }); diff --git a/src/services/dependencies.service.js b/src/services/dependencies.service.js index 85275a1d..5ffbfd03 100644 --- a/src/services/dependencies.service.js +++ b/src/services/dependencies.service.js @@ -2,6 +2,8 @@ import { PACKAGE_MANAGER_CMD } from './platform.service'; import * as childProcess from 'child_process'; +import type { Dependency } from '../types'; + const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => new Promise((resolve, reject) => { const child = childProcess.spawn(cmd, cmdArgs, { @@ -14,21 +16,31 @@ const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => // logger(child) // service will be used here later }); -export const installDependency = ( +export const toPackageManagerArgs = (dependencies: Array) => { + return dependencies.map( + ({ name, version }: Dependency) => name + (version ? `@${version}` : '') + ); +}; + +export const installDependencies = ( projectPath: string, - dependencyName: string, - version: string + dependencies: Array ) => spawnProcess( PACKAGE_MANAGER_CMD, - ['add', `${dependencyName}@${version}`, '-SE'], + ['add', ...toPackageManagerArgs(dependencies), '-SE'], 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', ...toPackageManagerArgs(dependencies)], + 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..250b9950 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 { Dependency, DependencyLocation, ProjectInternal } from '../types'; /** * Load a project's package.json @@ -163,6 +163,46 @@ 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); + + // The results will be an array of package.jsons. + // I want a database-style map. + const dependenciesFromPackageJson = filteredResults.reduce( + (dependenciesMap, dependency) => ({ + ...dependenciesMap, + [dependency.name]: dependency, + }), + {} + ); + + resolve(dependenciesFromPackageJson); + } + ); + }); +} + /** * Wrapper around `loadProjectDependency` that fetches all dependencies for * a specific project. @@ -171,10 +211,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 => @@ -184,48 +221,12 @@ export function loadAllProjectDependencies( // 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); - - // The results will be an array of package.jsons. - // I want a database-style map. - const dependencies = filteredResults.reduce( - (dependenciesMap, dependency) => ({ - ...dependenciesMap, - [dependency.name]: dependency, - }), - {} - ); - - resolve(dependencies); - } - ); + const dependencies = [...deps, ...devDeps].map(name => ({ + name, + location: devDeps.includes(name) ? 'devDependencies' : 'dependencies', + })); + + return loadProjectDependencies(projectPath, dependencies); }) ); } From 29d7d987445d975e62e4860244a7d1f321fc63ba Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Fri, 31 Aug 2018 03:20:54 -0400 Subject: [PATCH 3/7] update UI to use new queue methods --- src/actions/index.js | 22 +++++--------- .../AddDependencySearchResult.js | 21 +++---------- .../DeleteDependencyButton.js | 22 ++++---------- .../DependencyUpdateRow.js | 21 ++++--------- src/reducers/projects.reducer.js | 12 ++++---- src/reducers/projects.reducer.test.js | 30 ++++++++++--------- src/services/read-from-disk.service.js | 2 +- 7 files changed, 46 insertions(+), 84 deletions(-) diff --git a/src/actions/index.js b/src/actions/index.js index 5651e889..3c9603df 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -2,7 +2,6 @@ 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'; @@ -90,20 +89,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, + }); + }); }; }; diff --git a/src/components/AddDependencySearchResult/AddDependencySearchResult.js b/src/components/AddDependencySearchResult/AddDependencySearchResult.js index 0ca00ec2..ce9af9f2 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'; @@ -29,8 +28,7 @@ import type { DependencyStatus } from '../../types'; type Props = { projectId: string, currentStatus: ?DependencyStatus, - isPackageJsonLocked: boolean, - addDependencyStart: ( + addDependency: ( projectId: string, dependencyName: string, version: string @@ -77,13 +75,7 @@ const getColorForDownloadNumber = (num: number) => { class AddDependencySearchResult extends PureComponent { renderActionArea() { - const { - hit, - projectId, - currentStatus, - isPackageJsonLocked, - addDependencyStart, - } = this.props; + const { hit, projectId, currentStatus, addDependencyStart } = this.props; if (currentStatus === 'installing') { return ( @@ -111,8 +103,7 @@ class AddDependencySearchResult extends PureComponent { size="small" color1={COLORS.green[700]} color2={COLORS.lightGreen[500]} - textColor={isPackageJsonLocked ? COLORS.gray[400] : COLORS.green[700]} - disabled={isPackageJsonLocked} + textColor={COLORS.green[700]} onClick={() => addDependencyStart(projectId, hit.name, hit.version)} > Add To Project @@ -273,14 +264,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..ef07cc34 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'; @@ -18,8 +17,7 @@ type Props = { dependencyName: string, isBeingDeleted?: boolean, // 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 +25,7 @@ type Props = { // an actively-used dependency? class DeleteDependencyButton extends PureComponent { handleClick = () => { - const { projectId, dependencyName, deleteDependencyStart } = this.props; + const { projectId, dependencyName, deleteDependency } = this.props; dialog.showMessageBox( { @@ -44,14 +42,14 @@ class DeleteDependencyButton extends PureComponent { const isConfirmed = response === 0; if (isConfirmed) { - deleteDependencyStart(projectId, dependencyName); + deleteDependency(projectId, dependencyName); } } ); }; render() { - const { isBeingDeleted, isPackageJsonLocked } = this.props; + const { isBeingDeleted } = this.props; return ( diff --git a/src/reducers/queue.reducer.js b/src/reducers/queue.reducer.js index a7e03b78..4be94015 100644 --- a/src/reducers/queue.reducer.js +++ b/src/reducers/queue.reducer.js @@ -7,15 +7,7 @@ import { } from '../actions'; import type { Action } from 'redux'; -import type { Dependency, QueueAction } from '../actions'; - -// Installing a new dependency and updating an existing one -// both use the same yarn command (yarn add) and as such can -// be bulked together into a single install. However, we want -// to display either 'Installing...' or 'Updating...', respectively, -// so we have to track the install's purpose per dependency in -// the install queue -type QueuedDependency = Dependency & { updating?: boolean }; +import type { QueuedDependency, QueueAction } from '../types'; type State = { [projectId: string]: Array<{ diff --git a/src/sagas/dependency.saga.js b/src/sagas/dependency.saga.js index 98415f43..62484956 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -46,15 +46,14 @@ export function* addDependency({ getPackageJsonLockedForProjectId, projectId ); - const dependency = { name: dependencyName, version }; // if there are ongoing actions, queue this dependency if (packageJsonLocked) { - yield put(queueDependencyInstall(projectId, dependency)); + yield put(queueDependencyInstall(projectId, dependencyName, version)); } else { // if the queue for this project is empty, go ahead and install // the dependency - yield put(installDependencyStart(projectId, dependency)); + yield put(installDependencyStart(projectId, dependencyName, version)); } } @@ -67,16 +66,15 @@ export function* updateDependency({ getPackageJsonLockedForProjectId, projectId ); - const dependency = { - name: dependencyName, - version: latestVersion, - updating: true, - }; if (packageJsonLocked) { - yield put(queueDependencyInstall(projectId, dependency)); + yield put( + queueDependencyInstall(projectId, dependencyName, latestVersion, true) + ); } else { - yield put(installDependencyStart(projectId, dependency)); + yield put( + installDependencyStart(projectId, dependencyName, latestVersion, true) + ); } } @@ -88,12 +86,11 @@ export function* deleteDependency({ getPackageJsonLockedForProjectId, projectId ); - const dependency = { name: dependencyName }; if (packageJsonLocked) { - yield put(queueDependencyUninstall(projectId, dependency)); + yield put(queueDependencyUninstall(projectId, dependencyName)); } else { - yield put(uninstallDependencyStart(projectId, dependency)); + yield put(uninstallDependencyStart(projectId, dependencyName)); } } diff --git a/src/sagas/dependency.saga.test.js b/src/sagas/dependency.saga.test.js index f377b2c4..d1c25537 100644 --- a/src/sagas/dependency.saga.test.js +++ b/src/sagas/dependency.saga.test.js @@ -69,7 +69,9 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(installDependencyStart(projectId, dependency)) + put( + installDependencyStart(projectId, dependency.name, dependency.version) + ) ); expect(saga.next().done).toBe(true); }); @@ -81,7 +83,9 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(queueDependencyInstall(projectId, dependency)) + put( + queueDependencyInstall(projectId, dependency.name, dependency.version) + ) ); expect(saga.next().done).toBe(true); }); @@ -111,7 +115,14 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(installDependencyStart(projectId, dependency)) + put( + installDependencyStart( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) ); expect(saga.next().done).toBe(true); }); @@ -123,7 +134,14 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(queueDependencyInstall(projectId, dependency)) + put( + queueDependencyInstall( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) ); expect(saga.next().done).toBe(true); }); @@ -151,7 +169,7 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(uninstallDependencyStart(projectId, dependency)) + put(uninstallDependencyStart(projectId, dependency.name)) ); expect(saga.next().done).toBe(true); }); @@ -163,7 +181,7 @@ describe('Dependency sagas', () => { select(getPackageJsonLockedForProjectId, projectId) ); expect(saga.next(packageJsonLocked).value).toEqual( - put(queueDependencyUninstall(projectId, dependency)) + put(queueDependencyUninstall(projectId, dependency.name)) ); expect(saga.next().done).toBe(true); }); diff --git a/src/services/dependencies.service.js b/src/services/dependencies.service.js index 5ffbfd03..b071ba63 100644 --- a/src/services/dependencies.service.js +++ b/src/services/dependencies.service.js @@ -2,7 +2,7 @@ import { PACKAGE_MANAGER_CMD } from './platform.service'; import * as childProcess from 'child_process'; -import type { Dependency } from '../types'; +import type { QueuedDependency } from '../types'; const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => new Promise((resolve, reject) => { @@ -16,15 +16,18 @@ const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => // logger(child) // service will be used here later }); -export const toPackageManagerArgs = (dependencies: Array) => { +export const toPackageManagerArgs = ( + dependencies: Array +): Array => { return dependencies.map( - ({ name, version }: Dependency) => name + (version ? `@${version}` : '') + ({ name, version }: QueuedDependency) => + name + (version ? `@${version}` : '') ); }; export const installDependencies = ( projectPath: string, - dependencies: Array + dependencies: Array ) => spawnProcess( PACKAGE_MANAGER_CMD, @@ -34,7 +37,7 @@ export const installDependencies = ( export const uninstallDependencies = ( projectPath: string, - dependencies: Array + dependencies: Array ) => spawnProcess( PACKAGE_MANAGER_CMD, diff --git a/src/services/read-from-disk.service.js b/src/services/read-from-disk.service.js index 0f444925..9fa933d9 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 { Dependency, DependencyLocation } from '../types'; +import type { QueuedDependency, DependencyLocation } from '../types'; /** * Load a project's package.json @@ -169,7 +169,7 @@ export function loadProjectDependency( */ export function loadProjectDependencies( projectPath: string, - dependencies: Array + dependencies: Array ) { return new Promise((resolve, reject) => { asyncMap( diff --git a/src/types.js b/src/types.js index fe34fc50..6e5bddd6 100644 --- a/src/types.js +++ b/src/types.js @@ -26,6 +26,11 @@ export type DependencyStatus = | '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, From 791011ac333725530b348fd0d7dd75f0a5f90118 Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Fri, 31 Aug 2018 21:37:30 -0400 Subject: [PATCH 5/7] update UI to reflect new queue/batch actions - update tests to reflect structural changes - exclude `queue` state from redux-storage --- src/actions/index.js | 2 + .../AddDependencySearchResult.js | 49 ++++++++++++------- .../DeleteDependencyButton.js | 24 ++++++--- .../DependencyDetailsTable.js | 2 +- .../DependencyInstalling.js | 14 ++++-- .../DependencyManagementPane.js | 13 +++-- .../__snapshots__/queue.reducer.test.js.snap | 3 ++ src/reducers/queue.reducer.js | 23 ++++++++- src/sagas/dependency.saga.js | 26 +++++----- src/sagas/dependency.saga.test.js | 18 +++++++ src/services/read-from-disk.service.js | 30 +++++++----- src/store/index.js | 6 +-- 12 files changed, 145 insertions(+), 65 deletions(-) diff --git a/src/actions/index.js b/src/actions/index.js index 393c429d..e1a1d110 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -214,6 +214,7 @@ export const installDependenciesStart = ( dependencies: Array ) => ({ type: INSTALL_DEPENDENCIES_START, + projectId, dependencies, }); @@ -247,6 +248,7 @@ export const uninstallDependenciesStart = ( dependencies: Array ) => ({ type: UNINSTALL_DEPENDENCIES_START, + projectId, dependencies, }); diff --git a/src/components/AddDependencySearchResult/AddDependencySearchResult.js b/src/components/AddDependencySearchResult/AddDependencySearchResult.js index 543311cc..dccd7e9d 100644 --- a/src/components/AddDependencySearchResult/AddDependencySearchResult.js +++ b/src/components/AddDependencySearchResult/AddDependencySearchResult.js @@ -77,15 +77,7 @@ class AddDependencySearchResult extends PureComponent { renderActionArea() { const { hit, projectId, currentStatus, addDependency } = this.props; - if (currentStatus === 'installing') { - return ( - - - - Installing... - - ); - } else if (typeof currentStatus === 'string') { + if (currentStatus === 'idle') { return ( { Installed ); - } else { + } + + if (currentStatus) { return ( - + + + + { + { + installing: 'Installing..', + updating: 'Updating..', + deleting: 'Deleting..', + 'queued-install': 'Queued for Install', + 'queued-update': 'Queued for Update', + 'queued-delete': 'Queued for Delete', + }[currentStatus] + } + ); } + + return ( + + ); } render() { diff --git a/src/components/DeleteDependencyButton/DeleteDependencyButton.js b/src/components/DeleteDependencyButton/DeleteDependencyButton.js index ef07cc34..6fc98448 100644 --- a/src/components/DeleteDependencyButton/DeleteDependencyButton.js +++ b/src/components/DeleteDependencyButton/DeleteDependencyButton.js @@ -15,7 +15,7 @@ const { dialog } = remote; type Props = { projectId: string, dependencyName: string, - isBeingDeleted?: boolean, + dependencyStatus: string, // From redux: deleteDependency: (projectId: string, dependencyName: string) => any, }; @@ -25,7 +25,16 @@ type Props = { // an actively-used dependency? class DeleteDependencyButton extends PureComponent { handleClick = () => { - const { projectId, dependencyName, deleteDependency } = 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( { @@ -49,7 +58,8 @@ class DeleteDependencyButton extends PureComponent { }; render() { - const { isBeingDeleted } = this.props; + const { dependencyStatus } = this.props; + return ( ); 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..8b96b7c7 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({ @@ -170,8 +171,12 @@ class DependencyManagementPane extends PureComponent { - {selectedDependency.status === 'installing' ? ( - + {selectedDependency.status === 'installing' || + selectedDependency.status === 'queued-install' ? ( + ) : ( , }>, }; @@ -46,10 +49,13 @@ export default (state: State = initialState, action: Action) => { // get existing install queue for this project, or // create it if it doesn't exist - let installQueue = projectQueue.find(q => q.action === 'install'); + let installQueue = projectQueue.find( + q => q.action === 'install' && !q.active + ); if (!installQueue) { installQueue = { action: 'install', + active: false, dependencies: [], }; projectQueue.push(installQueue); @@ -77,10 +83,13 @@ export default (state: State = initialState, action: Action) => { // get existing uninstall queue for this project, or // create it if it doesn't exist - let installQueue = projectQueue.find(q => q.action === 'uninstall'); + let installQueue = projectQueue.find( + q => q.action === 'uninstall' && !q.active + ); if (!installQueue) { installQueue = { action: 'uninstall', + active: false, dependencies: [], }; projectQueue.push(installQueue); @@ -96,6 +105,16 @@ export default (state: State = initialState, action: Action) => { }); } + 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; + }); + } + default: return state; } diff --git a/src/sagas/dependency.saga.js b/src/sagas/dependency.saga.js index 62484956..a773e033 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -47,12 +47,10 @@ export function* addDependency({ projectId ); - // if there are ongoing actions, queue this dependency - if (packageJsonLocked) { - yield put(queueDependencyInstall(projectId, dependencyName, version)); - } else { - // if the queue for this project is empty, go ahead and install - // the dependency + yield put(queueDependencyInstall(projectId, dependencyName, version)); + + // if there are no other ongoing operations, begin install + if (!packageJsonLocked) { yield put(installDependencyStart(projectId, dependencyName, version)); } } @@ -67,11 +65,11 @@ export function* updateDependency({ projectId ); - if (packageJsonLocked) { - yield put( - queueDependencyInstall(projectId, dependencyName, latestVersion, true) - ); - } else { + yield put( + queueDependencyInstall(projectId, dependencyName, latestVersion, true) + ); + + if (!packageJsonLocked) { yield put( installDependencyStart(projectId, dependencyName, latestVersion, true) ); @@ -87,9 +85,9 @@ export function* deleteDependency({ projectId ); - if (packageJsonLocked) { - yield put(queueDependencyUninstall(projectId, dependencyName)); - } else { + yield put(queueDependencyUninstall(projectId, dependencyName)); + + if (!packageJsonLocked) { yield put(uninstallDependencyStart(projectId, dependencyName)); } } diff --git a/src/sagas/dependency.saga.test.js b/src/sagas/dependency.saga.test.js index d1c25537..87d1490a 100644 --- a/src/sagas/dependency.saga.test.js +++ b/src/sagas/dependency.saga.test.js @@ -68,6 +68,11 @@ describe('Dependency sagas', () => { expect(saga.next().value).toEqual( select(getPackageJsonLockedForProjectId, projectId) ); + expect(saga.next(packageJsonLocked).value).toEqual( + put( + queueDependencyInstall(projectId, dependency.name, dependency.version) + ) + ); expect(saga.next(packageJsonLocked).value).toEqual( put( installDependencyStart(projectId, dependency.name, dependency.version) @@ -114,6 +119,16 @@ describe('Dependency sagas', () => { expect(saga.next().value).toEqual( select(getPackageJsonLockedForProjectId, projectId) ); + expect(saga.next(packageJsonLocked).value).toEqual( + put( + queueDependencyInstall( + projectId, + dependency.name, + dependency.version, + dependency.updating + ) + ) + ); expect(saga.next(packageJsonLocked).value).toEqual( put( installDependencyStart( @@ -168,6 +183,9 @@ describe('Dependency sagas', () => { expect(saga.next().value).toEqual( select(getPackageJsonLockedForProjectId, projectId) ); + expect(saga.next(packageJsonLocked).value).toEqual( + put(queueDependencyUninstall(projectId, dependency.name)) + ); expect(saga.next(packageJsonLocked).value).toEqual( put(uninstallDependencyStart(projectId, dependency.name)) ); diff --git a/src/services/read-from-disk.service.js b/src/services/read-from-disk.service.js index 9fa933d9..1526a07b 100644 --- a/src/services/read-from-disk.service.js +++ b/src/services/read-from-disk.service.js @@ -187,17 +187,7 @@ export function loadProjectDependencies( // Filter out any unloaded dependencies const filteredResults = results.filter(result => result); - // The results will be an array of package.jsons. - // I want a database-style map. - const dependenciesFromPackageJson = filteredResults.reduce( - (dependenciesMap, dependency) => ({ - ...dependenciesMap, - [dependency.name]: dependency, - }), - {} - ); - - resolve(dependenciesFromPackageJson); + resolve(filteredResults); } ); }); @@ -220,13 +210,27 @@ export function loadAllProjectDependencies(projectPath: string) { // 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 devDeps = Object.keys(packageJson.devDependencies || {}); const dependencies = [...deps, ...devDeps].map(name => ({ name, location: devDeps.includes(name) ? 'devDependencies' : 'dependencies', })); - return loadProjectDependencies(projectPath, dependencies); + loadProjectDependencies(projectPath, dependencies).then( + dependenciesFromPackageJson => { + // The results will be an array of package.jsons. + // I want a database-style map. + const dependenciesByName = dependenciesFromPackageJson.reduce( + (dependenciesMap, dependency) => ({ + ...dependenciesMap, + [dependency.name]: dependency, + }), + {} + ); + + 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); From 7deb8bd2f7d4628bbb55638c864eece1c12d18c1 Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Sat, 1 Sep 2018 03:49:38 -0400 Subject: [PATCH 6/7] add queue indicator to dependency list --- .../DependencyManagementPane.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/DependencyManagementPane/DependencyManagementPane.js b/src/components/DependencyManagementPane/DependencyManagementPane.js index 8b96b7c7..ec861d01 100644 --- a/src/components/DependencyManagementPane/DependencyManagementPane.js +++ b/src/components/DependencyManagementPane/DependencyManagementPane.js @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import IconBase from 'react-icons-kit'; -import { plus } from 'react-icons-kit/feather/plus'; +import { moreHorizontal, plus } from 'react-icons-kit/feather'; import { runTask, abortTask } from '../../actions'; import { getSelectedProject } from '../../reducers/projects.reducer'; @@ -132,6 +132,17 @@ class DependencyManagementPane extends PureComponent { : undefined } /> + ) : dependency.status.match(/^queued-/) ? ( + + + ) : ( Date: Sun, 2 Sep 2018 04:04:21 -0400 Subject: [PATCH 7/7] resolve review comments --- .../AddDependencySearchResult.js | 24 +- .../DeleteDependencyButton.js | 10 +- .../DependencyManagementPane.js | 82 +++--- .../dependencies.reducer.test.js.snap | 233 ----------------- .../__snapshots__/queue.reducer.test.js.snap | 155 ------------ src/reducers/dependencies.reducer.js | 7 +- src/reducers/dependencies.reducer.test.js | 239 +++++++++++++++++- src/reducers/queue.reducer.js | 57 +++-- src/reducers/queue.reducer.test.js | 227 +++++++++++++---- src/sagas/dependency.saga.js | 70 ++--- src/sagas/dependency.saga.test.js | 129 ++++++---- src/services/dependencies.service.js | 28 +- 12 files changed, 642 insertions(+), 619 deletions(-) delete mode 100644 src/reducers/__snapshots__/dependencies.reducer.test.js.snap delete mode 100644 src/reducers/__snapshots__/queue.reducer.test.js.snap diff --git a/src/components/AddDependencySearchResult/AddDependencySearchResult.js b/src/components/AddDependencySearchResult/AddDependencySearchResult.js index dccd7e9d..70df5d8f 100644 --- a/src/components/AddDependencySearchResult/AddDependencySearchResult.js +++ b/src/components/AddDependencySearchResult/AddDependencySearchResult.js @@ -25,6 +25,16 @@ 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, @@ -76,8 +86,9 @@ const getColorForDownloadNumber = (num: number) => { class AddDependencySearchResult extends PureComponent { renderActionArea() { const { hit, projectId, currentStatus, addDependency } = this.props; + const isAlreadyInstalled = currentStatus === 'idle'; - if (currentStatus === 'idle') { + if (isAlreadyInstalled) { return ( { - { - { - installing: 'Installing..', - updating: 'Updating..', - deleting: 'Deleting..', - 'queued-install': 'Queued for Install', - 'queued-update': 'Queued for Update', - 'queued-delete': 'Queued for Delete', - }[currentStatus] - } + {DEPENDENCY_ACTIONS_COPY[currentStatus]} ); } diff --git a/src/components/DeleteDependencyButton/DeleteDependencyButton.js b/src/components/DeleteDependencyButton/DeleteDependencyButton.js index 6fc98448..cbe958cc 100644 --- a/src/components/DeleteDependencyButton/DeleteDependencyButton.js +++ b/src/components/DeleteDependencyButton/DeleteDependencyButton.js @@ -12,6 +12,11 @@ import PixelShifter from '../PixelShifter'; const { dialog } = remote; +const DEPENDENCY_DELETE_COPY = { + idle: 'Delete', + 'queued-delete': 'Queued for Delete…', +}; + type Props = { projectId: string, dependencyName: string, @@ -76,10 +81,7 @@ class DeleteDependencyButton extends PureComponent { ) : ( - { - idle: 'Delete', - 'queued-delete': 'Queued for Delete..', - }[dependencyStatus] + DEPENDENCY_DELETE_COPY[dependencyStatus] )} ); diff --git a/src/components/DependencyManagementPane/DependencyManagementPane.js b/src/components/DependencyManagementPane/DependencyManagementPane.js index ec861d01..654c2d72 100644 --- a/src/components/DependencyManagementPane/DependencyManagementPane.js +++ b/src/components/DependencyManagementPane/DependencyManagementPane.js @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import IconBase from 'react-icons-kit'; -import { moreHorizontal, plus } from 'react-icons-kit/feather'; +import { plus } from 'react-icons-kit/feather/plus'; import { runTask, abortTask } from '../../actions'; import { getSelectedProject } from '../../reducers/projects.reducer'; @@ -102,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; @@ -123,32 +161,9 @@ class DependencyManagementPane extends PureComponent { onClick={() => this.selectDependency(dependency.name)} > {dependency.name} - {dependency.status === 'installing' ? ( - - ) : dependency.status.match(/^queued-/) ? ( - - - - ) : ( - - {dependency.version} - + {this.renderListAddon( + dependency, + selectedDependencyIndex === index )} ))} @@ -182,18 +197,7 @@ class DependencyManagementPane extends PureComponent { - {selectedDependency.status === 'installing' || - selectedDependency.status === 'queued-install' ? ( - - ) : ( - - )} + {this.renderMainContents(selectedDependency, id)} diff --git a/src/reducers/__snapshots__/dependencies.reducer.test.js.snap b/src/reducers/__snapshots__/dependencies.reducer.test.js.snap deleted file mode 100644 index 6b7b8e7b..00000000 --- a/src/reducers/__snapshots__/dependencies.reducer.test.js.snap +++ /dev/null @@ -1,233 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dependencies reducer should handle ADD_DEPENDENCY 1`] = ` -Object { - "foo": Object { - "redux": Object { - "description": "", - "homepage": "", - "license": "", - "location": "dependencies", - "name": "redux", - "repository": Object { - "type": "", - "url": "", - }, - "status": "queued-install", - "version": "", - }, - }, -} -`; - -exports[`dependencies reducer should handle DELETE_DEPENDENCY 1`] = ` -Object { - "foo": 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-delete", - "version": "3.2", - }, - }, -} -`; - -exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_ERROR 1`] = ` -Object { - "foo": 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": "idle", - "version": "3.2", - }, - }, -} -`; - -exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_FINISH 1`] = ` -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", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "location": "dependencies", - "name": "redux", - "repository": Object { - "type": "git", - "url": "https://github.com/foo/bar.git", - }, - "status": "idle", - "version": "3.3", - }, - }, -} -`; - -exports[`dependencies reducer should handle INSTALL_DEPENDENCIES_START 1`] = ` -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", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "location": "dependencies", - "name": "redux", - "repository": Object { - "type": "git", - "url": "https://github.com/foo/bar.git", - }, - "status": "updating", - "version": "3.2", - }, - }, -} -`; - -exports[`dependencies reducer should handle LOAD_DEPENDENCY_INFO_FROM_DISK 1`] = ` -Object { - "baz": Object {}, - "foo": Object { - "redux": Object {}, - }, -} -`; - -exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_ERROR 1`] = ` -Object { - "foo": 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": "idle", - "version": "3.2", - }, - }, -} -`; - -exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_FINISH 1`] = ` -Object { - "foo": Object {}, -} -`; - -exports[`dependencies reducer should handle UNINSTALL_DEPENDENCIES_START 1`] = ` -Object { - "foo": Object { - "redux": Object { - "description": "dependency description", - "homepage": "https://dependency-homepage.io", - "keywords": Array [ - "key", - "words", - ], - "license": "MIT", - "name": "redux", - "repository": Object { - "type": "git", - "url": "https://github.com/foo/bar.git", - }, - "status": "deleting", - "version": "3.2", - }, - }, -} -`; - -exports[`dependencies reducer should handle UPDATE_DEPENDENCY 1`] = ` -Object { - "foo": 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", - }, - }, -} -`; diff --git a/src/reducers/__snapshots__/queue.reducer.test.js.snap b/src/reducers/__snapshots__/queue.reducer.test.js.snap deleted file mode 100644 index 7f7ca831..00000000 --- a/src/reducers/__snapshots__/queue.reducer.test.js.snap +++ /dev/null @@ -1,155 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`queue reducer getNextActionForProjectId should return next action when one is present 1`] = ` -Object { - "action": "install", - "dependencies": Array [ - Object { - "name": "redux", - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for mixed existing queue 1`] = ` -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", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for new dependency on empty queue 1`] = ` -Object { - "foo": Array [ - Object { - "action": "install", - "active": false, - "dependencies": Array [ - Object { - "name": "redux", - "updating": undefined, - "version": "3.2", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for new dependency on existing queue 1`] = ` -Object { - "foo": Array [ - Object { - "action": "install", - "dependencies": Array [ - Object { - "name": "react-redux", - }, - Object { - "name": "redux", - "updating": undefined, - "version": undefined, - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_INSTALL for updating dependency 1`] = ` -Object { - "foo": Array [ - Object { - "action": "install", - "active": false, - "dependencies": Array [ - Object { - "name": "redux", - "updating": true, - "version": "3.3", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_UNINSTALL 1`] = ` -Object { - "foo": Array [ - Object { - "action": "uninstall", - "active": false, - "dependencies": Array [ - Object { - "name": "redux", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle QUEUE_DEPENDENCY_UNINSTALL for mixed existing queue 1`] = ` -Object { - "foo": Array [ - Object { - "action": "install", - "dependencies": Array [ - Object { - "name": "react-redux", - }, - ], - }, - Object { - "action": "uninstall", - "dependencies": Array [ - Object { - "name": "redux", - }, - Object { - "name": "lodash", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle START_NEXT_ACTION_IN_QUEUE for queue with next action 1`] = ` -Object { - "foo": Array [ - Object { - "action": "uninstall", - "dependencies": Array [ - Object { - "name": "react-redux", - }, - ], - }, - ], -} -`; - -exports[`queue reducer should handle START_NEXT_ACTION_IN_QUEUE for queue with no more actions 1`] = `Object {}`; diff --git a/src/reducers/dependencies.reducer.js b/src/reducers/dependencies.reducer.js index bd6ab848..94095b14 100644 --- a/src/reducers/dependencies.reducer.js +++ b/src/reducers/dependencies.reducer.js @@ -102,11 +102,8 @@ export default (state: State = initialState, action: Action) => { return produce(state, draftState => { dependencies.forEach(dependency => { - draftState[projectId][dependency.name] = { - ...draftState[projectId][dependency.name], - ...dependency, - status: 'idle', - }; + draftState[projectId][dependency.name] = dependency; + draftState[projectId][dependency.name].status = 'idle'; }); }); } diff --git a/src/reducers/dependencies.reducer.test.js b/src/reducers/dependencies.reducer.test.js index 33939002..3821860c 100644 --- a/src/reducers/dependencies.reducer.test.js +++ b/src/reducers/dependencies.reducer.test.js @@ -28,7 +28,14 @@ describe('dependencies reducer', () => { dependencies: { redux: {} }, }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "baz": Object {}, + "foo": Object { + "redux": Object {}, + }, +} +`); }); it(`should handle ${ADD_DEPENDENCY}`, () => { @@ -42,7 +49,25 @@ describe('dependencies reducer', () => { dependencyName: 'redux', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Object { + "redux": Object { + "description": "", + "homepage": "", + "license": "", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "", + "url": "", + }, + "status": "queued-install", + "version": "", + }, + }, +} +`); }); it(`should handle ${UPDATE_DEPENDENCY}`, () => { @@ -69,7 +94,29 @@ describe('dependencies reducer', () => { latestVersion: '3.3', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": 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 ${DELETE_DEPENDENCY}`, () => { @@ -96,7 +143,29 @@ describe('dependencies reducer', () => { latestVersion: '3.3', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": 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-delete", + "version": "3.2", + }, + }, +} +`); }); it(`should handle ${INSTALL_DEPENDENCIES_START}`, () => { @@ -141,7 +210,42 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "updating", + "version": "3.2", + }, + }, +} +`); }); it(`should handle ${INSTALL_DEPENDENCIES_ERROR}`, () => { @@ -186,7 +290,29 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": 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": "idle", + "version": "3.2", + }, + }, +} +`); }); it(`should handle ${INSTALL_DEPENDENCIES_FINISH}`, () => { @@ -222,10 +348,17 @@ describe('dependencies reducer', () => { 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', @@ -236,7 +369,46 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "location": "dependencies", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "idle", + "version": "3.3", + }, + }, +} +`); }); it(`should handle ${UNINSTALL_DEPENDENCIES_START}`, () => { @@ -265,7 +437,28 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Object { + "redux": Object { + "description": "dependency description", + "homepage": "https://dependency-homepage.io", + "keywords": Array [ + "key", + "words", + ], + "license": "MIT", + "name": "redux", + "repository": Object { + "type": "git", + "url": "https://github.com/foo/bar.git", + }, + "status": "deleting", + "version": "3.2", + }, + }, +} +`); }); it(`should handle ${UNINSTALL_DEPENDENCIES_ERROR}`, () => { @@ -295,7 +488,29 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": 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": "idle", + "version": "3.2", + }, + }, +} +`); }); it(`should handle ${UNINSTALL_DEPENDENCIES_FINISH}`, () => { @@ -325,7 +540,11 @@ describe('dependencies reducer', () => { ], }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Object {}, +} +`); }); }); diff --git a/src/reducers/queue.reducer.js b/src/reducers/queue.reducer.js index ec7e3ab8..f320b758 100644 --- a/src/reducers/queue.reducer.js +++ b/src/reducers/queue.reducer.js @@ -5,40 +5,29 @@ import { QUEUE_DEPENDENCY_UNINSTALL, INSTALL_DEPENDENCIES_START, UNINSTALL_DEPENDENCIES_START, - START_NEXT_ACTION_IN_QUEUE, + 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<{ - action: QueueAction, - active: boolean, - dependencies: Array, - }>, + [projectId: string]: Array, }; const initialState = {}; export default (state: State = initialState, action: Action) => { switch (action.type) { - case START_NEXT_ACTION_IN_QUEUE: { - 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]; - } - }); - } - case QUEUE_DEPENDENCY_INSTALL: { const { projectId, name, version, updating } = action; @@ -115,6 +104,25 @@ export default (state: State = initialState, action: Action) => { }); } + 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; } @@ -124,10 +132,5 @@ export default (state: State = initialState, action: Action) => { // // // Selectors -export const getPackageJsonLockedForProjectId = ( - state: any, - projectId: string -) => !!state.queue[projectId]; - 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 index 5355c016..f9437e57 100644 --- a/src/reducers/queue.reducer.test.js +++ b/src/reducers/queue.reducer.test.js @@ -1,11 +1,9 @@ -import reducer, { - getPackageJsonLockedForProjectId, - getNextActionForProjectId, -} from './queue.reducer'; +import reducer, { getNextActionForProjectId } from './queue.reducer'; import { QUEUE_DEPENDENCY_INSTALL, QUEUE_DEPENDENCY_UNINSTALL, - START_NEXT_ACTION_IN_QUEUE, + INSTALL_DEPENDENCIES_START, + INSTALL_DEPENDENCIES_FINISH, } from '../actions'; describe('queue reducer', () => { @@ -13,7 +11,36 @@ describe('queue reducer', () => { expect(reducer(undefined, {})).toEqual({}); }); - it(`should handle ${START_NEXT_ACTION_IN_QUEUE} for queue with next action`, () => { + 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' }] }, @@ -22,24 +49,37 @@ describe('queue reducer', () => { }; const action = { - type: START_NEXT_ACTION_IN_QUEUE, + type: INSTALL_DEPENDENCIES_FINISH, projectId: 'foo', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "react-redux", + }, + ], + }, + ], +} +`); }); - it(`should handle ${START_NEXT_ACTION_IN_QUEUE} for queue with no more actions`, () => { + it(`should handle queue item completion for queue with no more actions`, () => { const prevState = { foo: [{ action: 'install', dependencies: [{ name: 'redux' }] }], }; const action = { - type: START_NEXT_ACTION_IN_QUEUE, + type: INSTALL_DEPENDENCIES_FINISH, projectId: 'foo', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + expect(reducer(prevState, action)).toMatchInlineSnapshot(`Object {}`); }); it(`should handle ${QUEUE_DEPENDENCY_INSTALL} for new dependency on empty queue`, () => { @@ -52,7 +92,23 @@ describe('queue reducer', () => { version: '3.2', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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`, () => { @@ -71,7 +127,25 @@ describe('queue reducer', () => { name: 'redux', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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`, () => { @@ -85,7 +159,23 @@ describe('queue reducer', () => { updating: true, }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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}`, () => { @@ -97,7 +187,21 @@ describe('queue reducer', () => { name: 'redux', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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`, () => { @@ -114,7 +218,33 @@ describe('queue reducer', () => { name: 'lodash', }; - expect(reducer(prevState, action)).toMatchSnapshot(); + 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`, () => { @@ -131,36 +261,31 @@ describe('queue reducer', () => { name: 'lodash', }; - expect(reducer(prevState, action)).toMatchSnapshot(); - }); - - describe('getPackageJsonLockedForProjectId', () => { - it('should return false when no dependencies are queued', () => { - const state = { - queue: {}, - }; - - const projectId = 'foo'; - - expect(getPackageJsonLockedForProjectId(state, projectId)).toBe(false); - }); - - it('should return true when dependencies are queued', () => { - const state = { - queue: { - foo: [ - { - action: 'install', - dependencies: [{ name: 'redux' }], - }, - ], + expect(reducer(prevState, action)).toMatchInlineSnapshot(` +Object { + "foo": Array [ + Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "react-redux", }, - }; - - const projectId = 'foo'; - - expect(getPackageJsonLockedForProjectId(state, projectId)).toBe(true); - }); + ], + }, + Object { + "action": "uninstall", + "dependencies": Array [ + Object { + "name": "redux", + }, + Object { + "name": "lodash", + }, + ], + }, + ], +} +`); }); describe('getNextActionForProjectId', () => { @@ -173,7 +298,17 @@ describe('queue reducer', () => { const projectId = 'foo'; - expect(getNextActionForProjectId(state, projectId)).toMatchSnapshot(); + expect(getNextActionForProjectId(state, projectId)) + .toMatchInlineSnapshot(` +Object { + "action": "install", + "dependencies": Array [ + Object { + "name": "redux", + }, + ], +} +`); }); it('should return undefined when no actions are present', () => { diff --git a/src/sagas/dependency.saga.js b/src/sagas/dependency.saga.js index a773e033..4b32acbc 100644 --- a/src/sagas/dependency.saga.js +++ b/src/sagas/dependency.saga.js @@ -1,10 +1,7 @@ // @flow import { select, call, put, takeEvery } from 'redux-saga/effects'; import { getPathForProjectId } from '../reducers/paths.reducer'; -import { - getPackageJsonLockedForProjectId, - getNextActionForProjectId, -} from '../reducers/queue.reducer'; +import { getNextActionForProjectId } from '../reducers/queue.reducer'; import { installDependencies, uninstallDependencies, @@ -37,62 +34,53 @@ import { import type { Action } from 'redux'; import type { Saga } from 'redux-saga'; -export function* addDependency({ +export function* handleAddDependency({ projectId, dependencyName, version, }: Action): Saga { - const packageJsonLocked = yield select( - getPackageJsonLockedForProjectId, - projectId - ); + const queuedAction = yield select(getNextActionForProjectId, projectId); yield put(queueDependencyInstall(projectId, dependencyName, version)); // if there are no other ongoing operations, begin install - if (!packageJsonLocked) { + if (!queuedAction) { yield put(installDependencyStart(projectId, dependencyName, version)); } } -export function* updateDependency({ +export function* handleUpdateDependency({ projectId, dependencyName, latestVersion, }: Action): Saga { - const packageJsonLocked = yield select( - getPackageJsonLockedForProjectId, - projectId - ); + const queuedAction = yield select(getNextActionForProjectId, projectId); yield put( queueDependencyInstall(projectId, dependencyName, latestVersion, true) ); - if (!packageJsonLocked) { + if (!queuedAction) { yield put( installDependencyStart(projectId, dependencyName, latestVersion, true) ); } } -export function* deleteDependency({ +export function* handleDeleteDependency({ projectId, dependencyName, }: Action): Saga { - const packageJsonLocked = yield select( - getPackageJsonLockedForProjectId, - projectId - ); + const queuedAction = yield select(getNextActionForProjectId, projectId); yield put(queueDependencyUninstall(projectId, dependencyName)); - if (!packageJsonLocked) { + if (!queuedAction) { yield put(uninstallDependencyStart(projectId, dependencyName)); } } -export function* startInstallingDependencies({ +export function* handleInstallDependenciesStart({ projectId, dependencies, }: Action): Saga { @@ -112,7 +100,7 @@ export function* startInstallingDependencies({ } } -export function* startUninstallingDependencies({ +export function* handleUninstallDependenciesStart({ projectId, dependencies, }: Action): Saga { @@ -132,14 +120,25 @@ export function* startUninstallingDependencies({ } export function* handleQueueActionCompleted({ projectId }: Action): Saga { - yield put(startNextActionInQueue(projectId)); + const nextAction = yield select(getNextActionForProjectId, projectId); + + // if there is another item in the queue, start it + if (nextAction) { + yield put(startNextActionInQueue(projectId)); + } } -export function* handleNextActionInQueue({ projectId }: Action): Saga { +export function* handleStartNextActionInQueue({ + projectId, +}: Action): Saga { const nextAction = yield select(getNextActionForProjectId, projectId); - // if the queue is empty, take no further action - if (!nextAction) return; + // 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' @@ -154,11 +153,14 @@ export function* handleNextActionInQueue({ projectId }: Action): Saga { // TODO: display an error message outside of the console when a dependency // action fails export default function* rootSaga(): Saga { - yield takeEvery(ADD_DEPENDENCY, addDependency); - yield takeEvery(UPDATE_DEPENDENCY, updateDependency); - yield takeEvery(DELETE_DEPENDENCY, deleteDependency); - yield takeEvery(INSTALL_DEPENDENCIES_START, startInstallingDependencies); - yield takeEvery(UNINSTALL_DEPENDENCIES_START, startUninstallingDependencies); + 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, @@ -168,5 +170,5 @@ export default function* rootSaga(): Saga { ], handleQueueActionCompleted ); - yield takeEvery(START_NEXT_ACTION_IN_QUEUE, handleNextActionInQueue); + 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 87d1490a..e0297704 100644 --- a/src/sagas/dependency.saga.test.js +++ b/src/sagas/dependency.saga.test.js @@ -1,18 +1,15 @@ import { select, call, put, takeEvery } from 'redux-saga/effects'; import rootSaga, { - addDependency, - updateDependency, - deleteDependency, - startInstallingDependencies, - startUninstallingDependencies, + handleAddDependency, + handleUpdateDependency, + handleDeleteDependency, + handleInstallDependenciesStart, + handleUninstallDependenciesStart, handleQueueActionCompleted, - handleNextActionInQueue, + handleStartNextActionInQueue, } from './dependency.saga'; import { getPathForProjectId } from '../reducers/paths.reducer'; -import { - getPackageJsonLockedForProjectId, - getNextActionForProjectId, -} from '../reducers/queue.reducer'; +import { getNextActionForProjectId } from '../reducers/queue.reducer'; import { installDependencies, uninstallDependencies, @@ -59,21 +56,21 @@ describe('Dependency sagas', () => { let saga; beforeEach(() => { - saga = addDependency(action); + saga = handleAddDependency(action); }); it('should immediately install on empty queue', () => { - const packageJsonLocked = false; + const queuedAction = null; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put( queueDependencyInstall(projectId, dependency.name, dependency.version) ) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next().value).toEqual( put( installDependencyStart(projectId, dependency.name, dependency.version) ) @@ -82,12 +79,16 @@ describe('Dependency sagas', () => { }); it('should queue install on non-empty queue', () => { - const packageJsonLocked = true; + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put( queueDependencyInstall(projectId, dependency.name, dependency.version) ) @@ -110,16 +111,16 @@ describe('Dependency sagas', () => { let saga; beforeEach(() => { - saga = updateDependency(action); + saga = handleUpdateDependency(action); }); it('should immediately install on empty queue', () => { - const packageJsonLocked = false; + const queuedAction = false; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put( queueDependencyInstall( projectId, @@ -129,7 +130,7 @@ describe('Dependency sagas', () => { ) ) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next().value).toEqual( put( installDependencyStart( projectId, @@ -143,12 +144,16 @@ describe('Dependency sagas', () => { }); it('should queue install on non-empty queue', () => { - const packageJsonLocked = true; + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put( queueDependencyInstall( projectId, @@ -174,31 +179,35 @@ describe('Dependency sagas', () => { let saga; beforeEach(() => { - saga = deleteDependency(action); + saga = handleDeleteDependency(action); }); it('should immediately uninstall on empty queue', () => { - const packageJsonLocked = false; + const queuedAction = null; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put(queueDependencyUninstall(projectId, dependency.name)) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next().value).toEqual( put(uninstallDependencyStart(projectId, dependency.name)) ); expect(saga.next().done).toBe(true); }); it('should queue uninstall on non-empty queue', () => { - const packageJsonLocked = true; + const queuedAction = { + action: 'install', + active: true, + dependencies: [{ name: 'redux' }], + }; expect(saga.next().value).toEqual( - select(getPackageJsonLockedForProjectId, projectId) + select(getNextActionForProjectId, projectId) ); - expect(saga.next(packageJsonLocked).value).toEqual( + expect(saga.next(queuedAction).value).toEqual( put(queueDependencyUninstall(projectId, dependency.name)) ); expect(saga.next().done).toBe(true); @@ -216,7 +225,7 @@ describe('Dependency sagas', () => { let saga; beforeEach(() => { - saga = startInstallingDependencies(action); + saga = handleInstallDependenciesStart(action); }); it('should install dependencies', () => { @@ -271,7 +280,7 @@ describe('Dependency sagas', () => { let saga; beforeEach(() => { - saga = startUninstallingDependencies(action); + saga = handleUninstallDependenciesStart(action); }); it('should uninstall dependencies', () => { @@ -305,10 +314,29 @@ describe('Dependency sagas', () => { }); describe('handleQueueActionCompleted saga', () => { - it(`should dispatch ${START_NEXT_ACTION_IN_QUEUE}`, () => { + 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(put(startNextActionInQueue(projectId))); + 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); }); }); @@ -316,17 +344,21 @@ describe('Dependency sagas', () => { describe('handleNextActionInQueue saga', () => { let saga; beforeEach(() => { - saga = handleNextActionInQueue({ projectId }); + saga = handleStartNextActionInQueue({ projectId }); }); it('should do nothing if the queue is empty', () => { - const nextAction = null; + const consoleErrorOriginal = global.console.error; + global.console.error = jest.fn(); expect(saga.next().value).toEqual( select(getNextActionForProjectId, projectId) ); - expect(saga.next(nextAction).value).toEqual(undefined); + 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`, () => { @@ -363,19 +395,22 @@ describe('Dependency sagas', () => { const saga = rootSaga(); expect(saga.next().value).toEqual( - takeEvery(ADD_DEPENDENCY, addDependency) + takeEvery(ADD_DEPENDENCY, handleAddDependency) ); expect(saga.next().value).toEqual( - takeEvery(UPDATE_DEPENDENCY, updateDependency) + takeEvery(UPDATE_DEPENDENCY, handleUpdateDependency) ); expect(saga.next().value).toEqual( - takeEvery(DELETE_DEPENDENCY, deleteDependency) + takeEvery(DELETE_DEPENDENCY, handleDeleteDependency) ); expect(saga.next().value).toEqual( - takeEvery(INSTALL_DEPENDENCIES_START, startInstallingDependencies) + takeEvery(INSTALL_DEPENDENCIES_START, handleInstallDependenciesStart) ); expect(saga.next().value).toEqual( - takeEvery(UNINSTALL_DEPENDENCIES_START, startUninstallingDependencies) + takeEvery( + UNINSTALL_DEPENDENCIES_START, + handleUninstallDependenciesStart + ) ); expect(saga.next().value).toEqual( takeEvery( @@ -389,7 +424,7 @@ describe('Dependency sagas', () => { ) ); expect(saga.next().value).toEqual( - takeEvery(START_NEXT_ACTION_IN_QUEUE, handleNextActionInQueue) + 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 b071ba63..383b2083 100644 --- a/src/services/dependencies.service.js +++ b/src/services/dependencies.service.js @@ -4,25 +4,37 @@ import * as childProcess from 'child_process'; import type { QueuedDependency } from '../types'; -const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => +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 toPackageManagerArgs = ( +export const getDependencyInstallationCommand = ( dependencies: Array ): Array => { - return dependencies.map( - ({ name, version }: QueuedDependency) => - name + (version ? `@${version}` : '') + const versionedDependencies = dependencies.map( + ({ name, version }) => name + (version ? `@${version}` : '') ); + + return ['add', ...versionedDependencies, '-SE']; }; export const installDependencies = ( @@ -31,7 +43,7 @@ export const installDependencies = ( ) => spawnProcess( PACKAGE_MANAGER_CMD, - ['add', ...toPackageManagerArgs(dependencies), '-SE'], + getDependencyInstallationCommand(dependencies), projectPath ); @@ -41,7 +53,7 @@ export const uninstallDependencies = ( ) => spawnProcess( PACKAGE_MANAGER_CMD, - ['remove', ...toPackageManagerArgs(dependencies)], + ['remove', ...dependencies.map(({ name }) => name)], projectPath );