From 90e1e4d49eaac5e378e334b0a257466a5bcc016a Mon Sep 17 00:00:00 2001 From: Mat Brown Date: Mon, 3 Apr 2017 10:21:18 -0700 Subject: [PATCH 1/4] Projects reducer consumes `USER_LOGGED_OUT` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user logs out, we want to clear all projects out of the list except for the current project. This requires the projects reducer to know what the current project is. There are several ways to solve this problem without giving the projects reducer access to the rest of the store: 1. Have the current project key passed in the action payload by the point of dispatch (e.g. from the Workspace component) 2. Use a thunk action creator that can introspect the current state and then add the current project key to a dispatched action payload 3. Duplicate the information in the `currentProject` subtree, also marking the object in `projects` as `current` 4. Don’t bother trimming the projects store–just enforce a rule that only the current project is visible using a selector However, each of these approaches has significant disadvantages: 1. The fact that a reducer needs to know about the current project when consuming `USER_LOGGED_OUT` is an implementation detail of the store; the component that initially dispatches the action should not need to know this 2. Thunk action creators are considered harmful and are being removed from our code 3. It’s a very good idea to keep the Redux store fully normalized. 4. This approach would lead to an incoherent store state, and we’d have roughly the same problem when the user logs in. Contra the author of [this highly upvoted GitHub issue comment](https://github.com/reactjs/redux/issues/601#issuecomment-196272085), I don’t think it’s an antipattern for reducers to have access to the entire store. In fact, this is the great strength of Redux—we’re able to model all state transitions based on a universally-agreed-upon answer to the question “what is the current state of the world?” The `combineReducers` approach to isolating the reducer logic for different parts of the subtree is a very useful tool for organizing code; it’s not a mandate to only organize code that way. Further, options 1 and 2 above feel a bit ridiculous because, fundamentally, **reducers do have access to the entire state**. Why would we jump through hoops just to give the reducer information it already has access to? So: establish a pattern that reducer modules may export a named `reduceRoot` function, which takes the entire state and performs reductions on it. The top-level root reducer will import this function and apply it to the state *after running the isolated reducers* using the `reduce-reducers` module. --- package.json | 1 + src/actions/user.js | 2 +- src/reducers/index.js | 10 +++++++--- src/reducers/projects.js | 34 +++++++++++++++++++++++----------- test/unit/reducers/projects.js | 17 ++++++++++++++++- yarn.lock | 2 +- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index d9bac43f90..588ab27f88 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "react-dom": "^15.4.1", "react-ga": "^2.1.2", "react-redux": "^5.0.3", + "reduce-reducers": "^0.1.2", "redux": "^3.6.0", "redux-actions": "^1.2.1", "redux-immutable": "^3.0.11", diff --git a/src/actions/user.js b/src/actions/user.js index 9080c3f359..72c8390098 100644 --- a/src/actions/user.js +++ b/src/actions/user.js @@ -8,7 +8,7 @@ export const userAuthenticated = createAction( const resetWorkspace = createAction('RESET_WORKSPACE', identity); -const userLoggedOut = createAction('USER_LOGGED_OUT'); +export const userLoggedOut = createAction('USER_LOGGED_OUT'); export function logOut() { return (dispatch, getState) => { diff --git a/src/reducers/index.js b/src/reducers/index.js index 3bc85182f7..116098e452 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,13 +1,14 @@ import {combineReducers} from 'redux-immutable'; +import reduceReducers from 'reduce-reducers'; import user from './user'; -import projects from './projects'; +import projects, {reduceRoot as reduceRootForProjects} from './projects'; import currentProject from './currentProject'; import errors from './errors'; import runtimeErrors from './runtimeErrors'; import ui from './ui'; import clients from './clients'; -const reducers = combineReducers({ +const reduceRoot = combineReducers({ user, projects, currentProject, @@ -17,4 +18,7 @@ const reducers = combineReducers({ clients, }); -export default reducers; +export default reduceReducers( + reduceRoot, + reduceRootForProjects, +); diff --git a/src/reducers/projects.js b/src/reducers/projects.js index bc308bb389..0866348bb9 100644 --- a/src/reducers/projects.js +++ b/src/reducers/projects.js @@ -64,7 +64,29 @@ function importGist(state, projectKey, gistData) { ); } -export default function projects(stateIn, action) { +export function reduceRoot(stateIn, action) { + return stateIn.update('projects', (projects) => { + switch (action.type) { + case 'USER_LOGGED_OUT': + { + const currentProjectKey = + stateIn.getIn(['currentProject', 'projectKey']); + + if (isNil(currentProjectKey)) { + return new Immutable.Map(); + } + + return new Immutable.Map().set( + currentProjectKey, + projects.get(currentProjectKey), + ); + } + } + return projects; + }); +} + +export default function reduceProjects(stateIn, action) { let state; if (stateIn === undefined) { @@ -95,16 +117,6 @@ export default function projects(stateIn, action) { case 'CHANGE_CURRENT_PROJECT': return removePristineExcept(state, action.payload.projectKey); - case 'RESET_WORKSPACE': - if (isNil(action.payload.currentProjectKey)) { - return emptyMap; - } - - return new Immutable.Map().set( - action.payload.currentProjectKey, - state.get(action.payload.currentProjectKey), - ); - case 'GIST_IMPORTED': return importGist( state, diff --git a/test/unit/reducers/projects.js b/test/unit/reducers/projects.js index b1cf9e8db5..cf2762b7b8 100644 --- a/test/unit/reducers/projects.js +++ b/test/unit/reducers/projects.js @@ -7,7 +7,9 @@ import Immutable from 'immutable'; import reducerTest from '../../helpers/reducerTest'; import {projects as states} from '../../helpers/referenceStates'; import {gistData, project} from '../../helpers/factory'; -import reducer from '../../../src/reducers/projects'; +import reducer, { + reduceRoot as rootReducer, +} from '../../../src/reducers/projects'; import { changeCurrentProject, gistImported, @@ -15,6 +17,7 @@ import { projectLoaded, projectSourceEdited, } from '../../../src/actions/projects'; +import {userLoggedOut} from '../../../src/actions/user'; const now = Date.now(); const projectKey = '12345'; @@ -136,6 +139,18 @@ tap(project(), projectIn => )), ); +tap(initProjects({1: true, 2: true}), projects => + test('userLoggedOut', reducerTest( + rootReducer, + Immutable.fromJS({currentProject: {projectKey: '1'}, projects}), + userLoggedOut, + Immutable.fromJS({ + currentProject: {projectKey: '1'}, + projects: projects.take(1), + }), + )), +); + function initProjects(map = {}) { return reduce(map, (projectsIn, modified, key) => { const projects = reducer(projectsIn, projectCreated(key)); diff --git a/yarn.lock b/yarn.lock index 741adb01c2..5010baf52a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6515,7 +6515,7 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" -reduce-reducers@^0.1.0: +reduce-reducers@^0.1.0, reduce-reducers@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b" From 2f2e466b8629d437a7692d451ba78ed54ea5ce8f Mon Sep 17 00:00:00 2001 From: Mat Brown Date: Mon, 3 Apr 2017 11:18:39 -0700 Subject: [PATCH 2/4] UI reducer consumes USER_LOGGED_OUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user logs out, and they have the projects list open, it should be closed (because logged out users can’t switch projects). --- src/reducers/ui.js | 13 ++++++++----- test/unit/reducers/ui.js | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 2756063290..a7ed71c4da 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -108,11 +108,14 @@ function ui(stateIn, action) { ), ); - case 'RESET_WORKSPACE': - return state.setIn( - ['dashboard', 'activeSubmenu'], - null, - ); + case 'USER_LOGGED_OUT': + if (state.getIn(['dashboard', 'activeSubmenu']) === 'projectList') { + return state.setIn( + ['dashboard', 'activeSubmenu'], + null, + ); + } + return state; default: return state; diff --git a/test/unit/reducers/ui.js b/test/unit/reducers/ui.js index 79bfad6676..96b007244f 100644 --- a/test/unit/reducers/ui.js +++ b/test/unit/reducers/ui.js @@ -7,7 +7,7 @@ import { gistNotFound, gistImportError, } from '../../../src/actions/projects'; - +import {userLoggedOut} from '../../../src/actions/user'; const initialState = Immutable.fromJS({ editors: {typing: false}, @@ -49,3 +49,26 @@ test('gistImportError', reducerTest( })), ), )); + +test('userLoggedOut', (t) => { + const libraryPickerOpen = initialState.setIn( + ['dashboard', 'activeSubmenu'], + 'libraryPicker', + ); + t.test('with active submenu that is not projects', reducerTest( + reducer, + libraryPickerOpen, + userLoggedOut, + libraryPickerOpen, + )); + + t.test('with projectList active submenu', reducerTest( + reducer, + initialState.setIn( + ['dashboard', 'activeSubmenu'], + 'projectList', + ), + userLoggedOut, + initialState, + )); +}); From a5744869406bd4420730532c1b3fc4f7a8108224 Mon Sep 17 00:00:00 2001 From: Mat Brown Date: Mon, 3 Apr 2017 11:20:54 -0700 Subject: [PATCH 3/4] Test for user reducer handling USER_LOGGED_OUT --- test/unit/reducers/user.js | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/unit/reducers/user.js b/test/unit/reducers/user.js index aa40af3536..6da3b9ff55 100644 --- a/test/unit/reducers/user.js +++ b/test/unit/reducers/user.js @@ -5,21 +5,30 @@ import reducerTest from '../../helpers/reducerTest'; import {user as states} from '../../helpers/referenceStates'; import {userCredential} from '../../helpers/factory'; import reducer from '../../../src/reducers/user'; -import {userAuthenticated} from '../../../src/actions/user'; +import {userAuthenticated, userLoggedOut} from '../../../src/actions/user'; const userCredentialIn = userCredential(); +const loggedInState = Immutable.fromJS({ + authenticated: true, + id: userCredentialIn.user.uid, + displayName: userCredentialIn.user.providerData[0].displayName, + avatarUrl: userCredentialIn.user.providerData[0].photoURL, + accessTokens: { + 'github.com': userCredentialIn.credential.accessToken, + }, +}); + test('userAuthenticated', reducerTest( reducer, states.initial, partial(userAuthenticated, userCredentialIn), - Immutable.fromJS({ - authenticated: true, - id: userCredentialIn.user.uid, - displayName: userCredentialIn.user.providerData[0].displayName, - avatarUrl: userCredentialIn.user.providerData[0].photoURL, - accessTokens: { - 'github.com': userCredentialIn.credential.accessToken, - }, - }), + loggedInState, +)); + +test('userLoggedOut', reducerTest( + reducer, + loggedInState, + userLoggedOut, + states.initial, )); From ccf73a8c0b3b1a48fbd5a74cf7b704b9e5b251b6 Mon Sep 17 00:00:00 2001 From: Mat Brown Date: Mon, 3 Apr 2017 11:28:27 -0700 Subject: [PATCH 4/4] Remove logOut thunk action creator And call `userLoggedOut` directly from `Workspace` --- spec/examples/actions/user.spec.js | 56 ------------------------------ src/actions/index.js | 4 +-- src/actions/user.js | 16 +-------- src/components/Workspace.jsx | 4 +-- 4 files changed, 5 insertions(+), 75 deletions(-) delete mode 100644 spec/examples/actions/user.spec.js diff --git a/spec/examples/actions/user.spec.js b/spec/examples/actions/user.spec.js deleted file mode 100644 index e6a7c06e79..0000000000 --- a/spec/examples/actions/user.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-env mocha */ -/* global sinon */ - -import {assert} from 'chai'; -import '../../helper'; -import MockFirebase, - {createUser} from '../../helpers/MockFirebase'; -import dispatchAndWait from '../../helpers/dispatchAndWait'; -import createAndMutateProject from '../../helpers/createAndMutateProject'; -import {getCurrentProject} from '../../../src/util/projectUtils'; -import {applicationLoaded, logOut} from '../../../src/actions'; -import createApplicationStore from '../../../src/createApplicationStore'; - -describe('user actions', () => { - let store, sandbox, mockFirebase; - - beforeEach(() => { - sandbox = sinon.sandbox.create(); - mockFirebase = new MockFirebase(sandbox); - store = createApplicationStore(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - const user = createUser(); - - describe('logOut', () => { - let loggedInProjectKey; - - beforeEach(async () => { - mockFirebase.logIn(user.uid); - mockFirebase.setCurrentProject(null); - await dispatchAndWait(store, applicationLoaded()); - createAndMutateProject(store); - loggedInProjectKey = getCurrentProject(store.getState()).projectKey; - return dispatchAndWait(store, logOut()); - }); - - it('should set authenticated to false', () => { - assert.isFalse(store.getState().getIn(['user', 'authenticated'])); - }); - - it('should set user id to null', () => { - assert.isUndefined(store.getState().getIn(['user', 'id'])); - }); - - it('should retain current project', () => { - assert.equal( - getCurrentProject(store.getState()).projectKey, - loggedInProjectKey, - ); - }); - }); -}); diff --git a/src/actions/index.js b/src/actions/index.js index d3ba5fab32..8535d98c00 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -29,7 +29,7 @@ import { import { userAuthenticated, - logOut, + userLoggedOut, } from './user'; function getCurrentPersistor(state) { @@ -173,7 +173,7 @@ export { updateProjectSource, toggleLibrary, userAuthenticated, - logOut, + userLoggedOut, addRuntimeError, clearRuntimeErrors, minimizeComponent, diff --git a/src/actions/user.js b/src/actions/user.js index 72c8390098..e24c9db97f 100644 --- a/src/actions/user.js +++ b/src/actions/user.js @@ -1,20 +1,6 @@ import {createAction} from 'redux-actions'; import identity from 'lodash/identity'; -export const userAuthenticated = createAction( - 'USER_AUTHENTICATED', - identity, -); - -const resetWorkspace = createAction('RESET_WORKSPACE', identity); +export const userAuthenticated = createAction('USER_AUTHENTICATED', identity); export const userLoggedOut = createAction('USER_LOGGED_OUT'); - -export function logOut() { - return (dispatch, getState) => { - const currentProjectKey = - getState().getIn(['currentProject', 'projectKey']); - dispatch(resetWorkspace({currentProjectKey})); - dispatch(userLoggedOut()); - }; -} diff --git a/src/components/Workspace.jsx b/src/components/Workspace.jsx index 41066317bf..f4e892574e 100644 --- a/src/components/Workspace.jsx +++ b/src/components/Workspace.jsx @@ -34,7 +34,7 @@ import { createProject, updateProjectSource, userAuthenticated, - logOut, + userLoggedOut, toggleLibrary, minimizeComponent, maximizeComponent, @@ -291,7 +291,7 @@ class Workspace extends React.Component { onSignedIn(userCredential => this.props.dispatch(userAuthenticated(userCredential)), ); - onSignedOut(() => this.props.dispatch(logOut())); + onSignedOut(() => this.props.dispatch(userLoggedOut())); } _handleStartLogIn() {