Skip to content
This repository has been archived by the owner on Jun 17, 2021. It is now read-only.

Redux saga for easy testing and extending #97

Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"electron-store": "2.0.0",
"fix-path": "2.1.0",
"gatsby-cli": "1.1.58",
"ps-tree": "1.1.0"
"ps-tree": "1.1.0",
"redux-saga": "^0.16.0"
},
"devDependencies": {
"@babel/core": "7.0.0-beta.44",
Expand Down
83 changes: 0 additions & 83 deletions src/middlewares/dependency.middleware.js

This file was deleted.

2 changes: 1 addition & 1 deletion src/middlewares/task.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default (store: any) => (next: any) => (action: any) => {
const state = store.getState();

const project = getProjectById(task.projectId, state);
const projectPath = getPathForProjectId(task.projectId, state);
const projectPath = getPathForProjectId(state, task.projectId);

// eslint-disable-next-line default-case
switch (action.type) {
Expand Down
2 changes: 1 addition & 1 deletion src/reducers/paths.reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@ export const getDefaultPath = (projectId: string) =>
//
//
// Selectors
export const getPathForProjectId = (projectId: string, state: any) =>
export const getPathForProjectId = (state: any, projectId: string) =>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, makes sense that we'd need to change the selectors to be state-first, for redux-saga select (it also just seems much more obvious, in retrospect, that this order makes more sense, since it matches how reducers work + allows for optional args afterwards).

I think we should try to update all selectors to follow this pattern, though, since I wouldn't want it to be inconsistent from reducer to reducer.

It might even make sense as a separate preliminary PR, to update all selectors (and their callsites) to be state-first... but I'm OK if we do it as part of this PR if that's too much git-juggling, haha.

state.paths[projectId] || getDefaultPath(projectId);
2 changes: 1 addition & 1 deletion src/reducers/projects.reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const prepareProjectForConsumption = (
createdAt: project.guppy.createdAt,
tasks: getTasksForProjectId(project.guppy.id, state),
dependencies: getDependenciesForProjectId(project.guppy.id, state),
path: getPathForProjectId(project.guppy.id, state),
path: getPathForProjectId(state, project.guppy.id),
};
};

Expand Down
81 changes: 81 additions & 0 deletions src/sagas/dependency.saga.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { select, call, put, takeEvery } from 'redux-saga/effects';
import { getPathForProjectId } from '../reducers/paths.reducer';
import {
installDependency,
uninstallDependency,
} from '../services/dependencies.service';
import { loadProjectDependency } from '../services/read-from-disk.service';
import {
ADD_DEPENDENCY_START,
UPDATE_DEPENDENCY_START,
DELETE_DEPENDENCY_START,
addDependencyFinish,
addDependencyError,
updateDependencyFinish,
updateDependencyError,
deleteDependencyFinish,
deleteDependencyError,
} from '../actions';

/**
* Trying to install new dependency, if success dispatching "finish" action
* if not - dispatching "error" ection
*/
export function* addDependency({ projectId, dependencyName, version }) {
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));
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look great 👍


/**
* Trying to update existing dependency, if success dispatching "finish" action,
* if not - dispatching "error" action
*/
export function* updateDependency({
projectId,
dependencyName,
latestVersion,
}) {
const projectPath = yield select(getPathForProjectId, projectId);
try {
yield call(installDependency, projectPath, dependencyName, latestVersion);
yield put(updateDependencyFinish(projectId, dependencyName, latestVersion));
} catch (err) {
yield call([console, 'error'], 'Failed to update dependency', err);
yield put(updateDependencyError(projectId, dependencyName));
}
}

/**
* Trying to delete dependency, if success dispatching "finish" action,
* if not - dispatching "error" action
*/
export function* deleteDependency({ projectId, dependencyName }) {
const projectPath = yield select(getPathForProjectId, projectId);
try {
yield call(uninstallDependency, projectPath, dependencyName);
yield put(deleteDependencyFinish(projectId, dependencyName));
} catch (err) {
yield call([console, 'error'], 'Failed to delete dependency', err);
yield put(deleteDependencyError(projectId, dependencyName));
}
}

/**
* Root dependencies saga, watching for "start" actions
*/
export default function* rootSaga() {
yield takeEvery(ADD_DEPENDENCY_START, addDependency);
yield takeEvery(UPDATE_DEPENDENCY_START, updateDependency);
yield takeEvery(DELETE_DEPENDENCY_START, deleteDependency);
}
52 changes: 52 additions & 0 deletions src/sagas/dependency.saga.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { select, call, put } from 'redux-saga/effects';
import { addDependency } from './dependency.saga';
import { getPathForProjectId } from '../reducers/paths.reducer';
import { installDependency } from '../services/dependencies.service';
import { loadProjectDependency } from '../services/read-from-disk.service';
import { addDependencyFinish, addDependencyError } from '../actions';

jest.mock('../services/read-from-disk.service');

describe('Dependency sagas', () => {
describe('addDependency saga', () => {
const startAction = {
projectId: 'foo',
dependencyName: 'redux',
version: '2.3',
};
const dependency = {
name: 'redux',
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')
);
expect(saga.next().value).toEqual(
call(loadProjectDependency, '/path/to/project/', 'redux')
);
expect(saga.next(dependency).value).toEqual(
put(addDependencyFinish('foo', dependency))
);
expect(saga.next().done).toBe(true);
});

it('should handle error', () => {
const error = new Error('something wrong');
const saga = addDependency(startAction);
expect(saga.next().value).toEqual(select(getPathForProjectId, 'foo'));
expect(saga.next('/path/to/project/').value).toEqual(
call(installDependency, '/path/to/project/', 'redux', '2.3')
);
expect(saga.throw(error).value).toEqual(
call([console, 'error'], 'Failed to install dependency', error)
);
expect(saga.next().value).toEqual(
put(addDependencyError('foo', 'redux'))
);
expect(saga.next().done).toBe(true);
});
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests look great!

Maybe just add a TODO comment for the other "branches"? Unless you plan on doing them as part of this PR, and were just waiting to see if I approved of sagas before spending the time?

});
6 changes: 6 additions & 0 deletions src/sagas/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { all } from 'redux-saga/effects';
import dependencySaga from './dependency.saga';

export default function*() {
yield all([dependencySaga()]);
}
11 changes: 8 additions & 3 deletions src/store/configure-store.dev.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
// @flow
import { createStore, compose, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

import rootReducer from '../reducers';
import { handleReduxUpdates } from '../services/redux-persistence.service';
import taskMiddleware from '../middlewares/task.middleware';
import dependencyMiddleware from '../middlewares/dependency.middleware';
import importProjectMiddleware from '../middlewares/import-project.middleware';
import rootSaga from '../sagas';

import DevTools from '../components/DevTools';

const sagaMiddleware = createSagaMiddleware();

export default function configureStore(initialState: any) {
const store = createStore(
rootReducer,
Expand All @@ -18,13 +21,15 @@ export default function configureStore(initialState: any) {
applyMiddleware(
thunk,
taskMiddleware,
dependencyMiddleware,
importProjectMiddleware
importProjectMiddleware,
sagaMiddleware
),
DevTools.instrument()
)
);

sagaMiddleware.run(rootSaga);

// Allow direct access to the store, for debugging/testing
window.store = store;

Expand Down
11 changes: 8 additions & 3 deletions src/store/configure-store.prod.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// @flow
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

import rootReducer from '../reducers';
import { handleReduxUpdates } from '../services/redux-persistence.service';
import taskMiddleware from '../middlewares/task.middleware';
import dependencyMiddleware from '../middlewares/dependency.middleware';
import importProjectMiddleware from '../middlewares/import-project.middleware';
import rootSaga from '../sagas';

const sagaMiddleware = createSagaMiddleware();

export default function configureStore(initialState: any) {
const store = createStore(
Expand All @@ -15,11 +18,13 @@ export default function configureStore(initialState: any) {
applyMiddleware(
thunk,
taskMiddleware,
dependencyMiddleware,
importProjectMiddleware
importProjectMiddleware,
sagaMiddleware
)
);

sagaMiddleware.run(rootSaga);

// Allow direct access to the store, for debugging/testing
// Doing this in production as well for the simple reason that this app is
// distributed to developers, and they may want to tinker with this :)
Expand Down
4 changes: 4 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7691,6 +7691,10 @@ [email protected]:
prop-types "^15.5.7"
redux-devtools-instrument "^1.0.1"

redux-saga@^0.16.0:
version "0.16.0"
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-0.16.0.tgz#0a231db0a1489301dd980f6f2f88d8ced418f724"

[email protected]:
version "2.2.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
Expand Down