From e7606b72118f71097a0a7f75fb735750f905c24a Mon Sep 17 00:00:00 2001 From: Joffrey BION Date: Sat, 13 May 2017 22:18:56 +0200 Subject: Migrate to seamless immutable Resolves: https://github.com/luxons/seven-wonders/issues/6 --- frontend/src/components/gameList.js | 7 +++-- frontend/src/components/playerList.js | 7 +++-- frontend/src/containers/gameBrowser.js | 4 +-- frontend/src/containers/lobby.js | 14 +++++++-- frontend/src/reducers.js | 22 +++---------- frontend/src/redux/app.js | 12 +------- frontend/src/redux/errors.js | 23 ++++++++++++++ frontend/src/redux/games.js | 30 +++++++++--------- frontend/src/redux/players.js | 23 +++++--------- frontend/src/routes.js | 4 +-- frontend/src/sagas.js | 7 +++-- frontend/src/sagas/errors.js | 23 ++++++++++++++ frontend/src/sagas/gameBrowser.js | 33 +++++++++----------- frontend/src/sagas/home.js | 29 +++++++++++++++++ frontend/src/sagas/usernameChoice.js | 41 ------------------------- frontend/src/store.js | 4 +-- frontend/src/utils/createWebSocketConnection.js | 14 --------- frontend/src/utils/websocket.js | 23 ++++++++++++++ 18 files changed, 169 insertions(+), 151 deletions(-) create mode 100644 frontend/src/redux/errors.js create mode 100644 frontend/src/sagas/errors.js create mode 100644 frontend/src/sagas/home.js delete mode 100644 frontend/src/sagas/usernameChoice.js delete mode 100644 frontend/src/utils/createWebSocketConnection.js create mode 100644 frontend/src/utils/websocket.js (limited to 'frontend/src') diff --git a/frontend/src/components/gameList.js b/frontend/src/components/gameList.js index c8720b26..f49a7589 100644 --- a/frontend/src/components/gameList.js +++ b/frontend/src/components/gameList.js @@ -1,15 +1,16 @@ import React from 'react' import { Flex } from 'reflexbox' import { Text, Space, Button } from 'rebass' +import Immutable from 'seamless-immutable' const GameList = (props) => (
- {props.games.map((game, index) => { + {Immutable.asMutable(props.games).map((game, index) => { - const joinGame = () => props.joinGame(game.get('id')) + const joinGame = () => props.joinGame(game.id) return ( - {game.get('name')} + {game.name} ) diff --git a/frontend/src/components/playerList.js b/frontend/src/components/playerList.js index 30384d53..45aa01a2 100644 --- a/frontend/src/components/playerList.js +++ b/frontend/src/components/playerList.js @@ -1,13 +1,14 @@ import React from 'react' import { Flex } from 'reflexbox' import { Text } from 'rebass' +import Immutable from 'seamless-immutable' const PlayerList = (props) => (
- {props.players.map((player, index) => { + {Immutable.asMutable(props.players).map((player, index) => { return ( - {player.get('displayName')} - ({player.get('username')}) + {player.displayName} + ({player.username}) ) })}
diff --git a/frontend/src/containers/gameBrowser.js b/frontend/src/containers/gameBrowser.js index 6a6b3ce2..5db3c1cb 100644 --- a/frontend/src/containers/gameBrowser.js +++ b/frontend/src/containers/gameBrowser.js @@ -30,7 +30,7 @@ class GameBrowser extends Component { > - Username: {this.props.currentPlayer.get('displayName')} + Username: {this.props.currentPlayer && this.props.currentPlayer.displayName} @@ -43,7 +43,7 @@ import { getCurrentPlayer } from '../redux/players' import { getAllGames, actions } from '../redux/games' const mapStateToProps = (state) => ({ - currentPlayer: getCurrentPlayer(state), + currentPlayer: getCurrentPlayer(state) || {displayName: '[ERROR]'}, games: getAllGames(state) }) diff --git a/frontend/src/containers/lobby.js b/frontend/src/containers/lobby.js index c36c3263..f0df0c44 100644 --- a/frontend/src/containers/lobby.js +++ b/frontend/src/containers/lobby.js @@ -1,15 +1,23 @@ import React, { Component } from 'react' import { connect } from 'react-redux' -import { List } from 'immutable' +import Immutable from 'seamless-immutable' import { Text } from 'rebass' import PlayerList from '../components/playerList' class Lobby extends Component { + getTitle() { + if (this.props.currentGame) { + return this.props.currentGame.name + } else { + return 'What are you doing here? You haven\'t joined a game yet!' + } + } + render() { return (
- {this.props.currentGame && {this.props.currentGame.name}} + {this.getTitle()}
) @@ -23,7 +31,7 @@ const mapStateToProps = (state) => { const game = getCurrentGame(state) return ({ currentGame: game, - players: game ? getPlayers(state, game.get('players')) : List() + players: game ? getPlayers(state, game.players) : Immutable([]) }) } diff --git a/frontend/src/reducers.js b/frontend/src/reducers.js index 097f9243..7e5fba26 100644 --- a/frontend/src/reducers.js +++ b/frontend/src/reducers.js @@ -1,28 +1,14 @@ -import { combineReducers } from 'redux-immutable' - -// react-router-redux immutable reducer -import { fromJS } from 'immutable' -import { LOCATION_CHANGE } from 'react-router-redux' - -const initialState = fromJS({ - locationBeforeTransitions: null -}) - -const routerImmutableReducer = (state = initialState, action) => { - if (action.type === LOCATION_CHANGE) { - return state.set('locationBeforeTransitions', action.payload) - } - - return state -} +import { combineReducers, routerReducer } from 'redux-seamless-immutable' +import errorsReducer from './redux/errors' import gamesReducer from './redux/games' import playersReducer from './redux/players' export default function createReducer() { return combineReducers({ + errors: errorsReducer, games: gamesReducer, - routing: routerImmutableReducer, players: playersReducer, + routing: routerReducer, }) } diff --git a/frontend/src/redux/app.js b/frontend/src/redux/app.js index 172dc960..251b12a2 100644 --- a/frontend/src/redux/app.js +++ b/frontend/src/redux/app.js @@ -1,15 +1,5 @@ export const makeSelectLocationState = () => { - let prevRoutingState; - let prevRoutingStateJS; - return (state) => { - const routingState = state.get('routing') - - if (!routingState.equals(prevRoutingState)) { - prevRoutingState = routingState - prevRoutingStateJS = routingState.toJS() - } - - return prevRoutingStateJS; + return state.routing } } diff --git a/frontend/src/redux/errors.js b/frontend/src/redux/errors.js new file mode 100644 index 00000000..1c247955 --- /dev/null +++ b/frontend/src/redux/errors.js @@ -0,0 +1,23 @@ +import Immutable from 'seamless-immutable' + +export const types = { + ERROR_RECEIVED_ON_WS: 'ERROR/RECEIVED_ON_WS', +} + +export const actions = { + errorReceived: (error) => ({ + type: types.ERROR_RECEIVED_ON_WS, + error + }) +} + +const initialState = Immutable.from([]) + +export default (state = initialState, action) => { + switch (action.type) { + case types.ERROR_RECEIVED_ON_WS: + return state.concat([ action.error ]) + default: + return state + } +} diff --git a/frontend/src/redux/games.js b/frontend/src/redux/games.js index e78e220f..2b9a92a4 100644 --- a/frontend/src/redux/games.js +++ b/frontend/src/redux/games.js @@ -1,4 +1,4 @@ -import {fromJS} from 'immutable' +import Immutable from 'seamless-immutable' export const types = { UPDATE_GAMES: 'GAME/UPDATE_GAMES', @@ -8,13 +8,13 @@ export const types = { } export const actions = { - updateGames: (games) => ({ type: types.UPDATE_GAMES, games }), - requestJoinGame: (id) => ({ type: types.REQUEST_JOIN_GAME, id }), - requestCreateGame: (name) => ({ type: types.REQUEST_CREATE_GAME, name }), - enterLobby: (lobby) => ({ type: types.ENTER_LOBBY, lobby }), + updateGames: (games) => ({ type: types.UPDATE_GAMES, games: Immutable(games) }), + requestJoinGame: (gameId) => ({ type: types.REQUEST_JOIN_GAME, gameId }), + requestCreateGame: (gameName) => ({ type: types.REQUEST_CREATE_GAME, gameName }), + enterLobby: (lobby) => ({ type: types.ENTER_LOBBY, lobby: Immutable(lobby) }), } -const initialState = fromJS({ +const initialState = Immutable.from({ all: {}, current: '' }) @@ -22,18 +22,18 @@ const initialState = fromJS({ export default (state = initialState, action) => { switch (action.type) { case types.UPDATE_GAMES: - return state.setIn(['all'], state.get('all').mergeDeep(action.games)) + return Immutable.merge(state, {all: action.games}, {deep: true}) case types.ENTER_LOBBY: - return state.set('current', action.lobby.get('id')) + return state.set('current', action.lobby.id) default: return state } } -const getState = globalState => globalState.get('games') - -export const getAllGamesById = globalState => getState(globalState).get('all') -export const getAllGames = globalState => getAllGamesById(globalState).toList() -export const getGame = (globalState, id) => getAllGamesById(globalState).get(String(id)) -export const getCurrentGameId = globalState => getState(globalState).get('current') -export const getCurrentGame = globalState => getGame(globalState, getCurrentGameId(globalState)) +export const getAllGamesById = state => state.games.all +export const getAllGames = state => { + let gamesById = getAllGamesById(state) + return Object.keys(gamesById).map(k => gamesById[k]); +} +export const getGame = (state, id) => getAllGamesById(state)[id] +export const getCurrentGame = state => getGame(state, state.games.current) diff --git a/frontend/src/redux/players.js b/frontend/src/redux/players.js index 09f7390b..84e24796 100644 --- a/frontend/src/redux/players.js +++ b/frontend/src/redux/players.js @@ -1,4 +1,4 @@ -import { fromJS, Map } from 'immutable' +import Immutable from 'seamless-immutable' export const types = { REQUEST_CHOOSE_USERNAME: 'USER/REQUEST_CHOOSE_USERNAME', @@ -21,7 +21,7 @@ export const actions = { }), } -const initialState = fromJS({ +const initialState = Immutable.from({ all: {}, current: '' }) @@ -30,22 +30,15 @@ export default (state = initialState, action) => { switch (action.type) { case types.SET_CURRENT_PLAYER: const player = action.player - const username = player.get('username') - return state.setIn(['all', username], player).set('current', username) + const withNewPlayer = state.setIn(['all', player.username], player) + return Immutable.set(withNewPlayer, 'current', player.username) case types.UPDATE_PLAYERS: - return state.setIn(['all'], state.get('all').mergeDeep(action.players)) + return Immutable.merge(state, {all: action.players}, {deep: true}) default: return state } } -const getState = globalState => globalState.get('players') - -export const getAllPlayersByUsername = globalState => getState(globalState).get('all') -export const getAllPlayers = globalState => getAllPlayersByUsername(globalState).toList() -export const getPlayers = (globalState, usernames) => getAllPlayersByUsername(globalState) - .filter((v, k) => usernames.contains(k)) - .toList() -export const getCurrentPlayerUsername = globalState => getState(globalState).get('current') -export const getCurrentPlayer = globalState => getAllPlayersByUsername(globalState) - .get(getCurrentPlayerUsername(globalState), Map()) +export const getCurrentPlayer = state => state.players.all && state.players.all[state.players.current] +export const getPlayers = (state, usernames) => Object.values(state.players.all) + .filter(p => usernames.indexOf(p.username) !== -1) diff --git a/frontend/src/routes.js b/frontend/src/routes.js index 16800736..1ce46bb3 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -1,10 +1,10 @@ import { fork } from 'redux-saga/effects' -import usernameChoiceSaga from './sagas/usernameChoice' +import homeSaga from './sagas/home' import gameBrowserSaga from './sagas/gameBrowser' export const makeSagaRoutes = wsConnection => ({ *'/'() { - yield fork(usernameChoiceSaga, wsConnection) + yield fork(homeSaga, wsConnection) }, *'/games'() { yield fork(gameBrowserSaga, wsConnection) diff --git a/frontend/src/sagas.js b/frontend/src/sagas.js index df49b099..c5166cd5 100644 --- a/frontend/src/sagas.js +++ b/frontend/src/sagas.js @@ -1,8 +1,9 @@ import { router } from 'redux-saga-router' -import { call } from 'redux-saga/effects' +import { call, fork } from 'redux-saga/effects' import { makeSagaRoutes } from './routes' -import createWsConnection from './utils/createWebSocketConnection' +import { createWsConnection } from './utils/websocket' +import errorHandlingSaga from './sagas/errors' export default function *rootSaga(history) { let wsConnection @@ -12,6 +13,6 @@ export default function *rootSaga(history) { console.error('Could not connect to socket') return } - + yield fork(errorHandlingSaga, wsConnection) yield* router(history, makeSagaRoutes(wsConnection)) } diff --git a/frontend/src/sagas/errors.js b/frontend/src/sagas/errors.js new file mode 100644 index 00000000..6d4df93d --- /dev/null +++ b/frontend/src/sagas/errors.js @@ -0,0 +1,23 @@ +import { apply, call, cancelled, put, take } from 'redux-saga/effects' + +import { createSubscriptionChannel } from '../utils/websocket' +import { actions } from '../redux/errors' + +export default function *errorHandlingSaga({ socket }) { + const errorChannel = yield call(createSubscriptionChannel, socket, '/user/queue/error') + try { + while (true) { + const error = yield take(errorChannel) + yield* handleOneError(error) + } + } finally { + if (yield cancelled()) { + yield apply(errorChannel, errorChannel.close) + } + } +} + +function *handleOneError(error) { + console.error("Error received on web socket channel", error) + yield put(actions.errorReceived(error)) +} diff --git a/frontend/src/sagas/gameBrowser.js b/frontend/src/sagas/gameBrowser.js index 596da428..4f3309c3 100644 --- a/frontend/src/sagas/gameBrowser.js +++ b/frontend/src/sagas/gameBrowser.js @@ -1,6 +1,5 @@ import { call, put, take, apply } from 'redux-saga/effects' -import { eventChannel} from 'redux-saga' -import { fromJS } from 'immutable' +import { eventChannel } from 'redux-saga' import { push } from 'react-router-redux' import { normalize } from 'normalizr' @@ -14,7 +13,7 @@ function gameBrowserChannel(socket) { const makeHandler = type => event => { const response = JSON.parse(event.body) - emit({ type, response }) + emit({type, response}) } const newGame = socket.subscribe('/topic/games', makeHandler(types.UPDATE_GAMES)) @@ -27,22 +26,22 @@ function gameBrowserChannel(socket) { }) } -export function *watchGames({ socket }) { +export function *watchGames({socket}) { const socketChannel = gameBrowserChannel(socket) try { while (true) { - const { type, response } = yield take(socketChannel) + const {type, response} = yield take(socketChannel) switch (type) { case types.UPDATE_GAMES: const normGameList = normalize(response, gameList) - yield put(playerActions.updatePlayers(fromJS(normGameList.entities.players))) - yield put(gameActions.updateGames(fromJS(normGameList.entities.games))) + yield put(playerActions.updatePlayers(normGameList.entities.players || {})) + yield put(gameActions.updateGames(normGameList.entities.games || {})) break case types.ENTER_LOBBY: const normGame = normalize(response, game) - yield put(gameActions.enterLobby(fromJS(normGame.entities.games[normGame.result]))) + yield put(gameActions.enterLobby(normGame.entities.games[normGame.result])) socketChannel.close() yield put(push('/lobby')) break @@ -55,24 +54,20 @@ export function *watchGames({ socket }) { } } -export function *createGame({ socket }) { - const { name } = yield take(types.REQUEST_CREATE_GAME) +export function *createGame({socket}) { + const {gameName} = yield take(types.REQUEST_CREATE_GAME) - yield apply(socket, socket.send, ["/app/lobby/create", JSON.stringify({ gameName: name }), {}]) + yield apply(socket, socket.send, ['/app/lobby/create', JSON.stringify({gameName}), {}]) } -export function *joinGame({ socket }) { - const { id } = yield take(types.REQUEST_JOIN_GAME) +export function *joinGame({socket}) { + const {gameId} = yield take(types.REQUEST_JOIN_GAME) - yield apply(socket, socket.send, ["/app/lobby/join", JSON.stringify({ gameId: id }), {}]) + yield apply(socket, socket.send, ['/app/lobby/join', JSON.stringify({gameId}), {}]) } export function *gameBrowserSaga(socketConnection) { - yield [ - call(watchGames, socketConnection), - call(createGame, socketConnection), - call(joinGame, socketConnection) - ] + yield [call(watchGames, socketConnection), call(createGame, socketConnection), call(joinGame, socketConnection)] } export default gameBrowserSaga diff --git a/frontend/src/sagas/home.js b/frontend/src/sagas/home.js new file mode 100644 index 00000000..99e6f954 --- /dev/null +++ b/frontend/src/sagas/home.js @@ -0,0 +1,29 @@ +import { apply, call, put, take } from 'redux-saga/effects' +import { createSubscriptionChannel } from '../utils/websocket' +import { push } from 'react-router-redux' + +import { actions, types } from '../redux/players' + +function *sendUsername({ socket }) { + const {username} = yield take(types.REQUEST_CHOOSE_USERNAME) + + yield apply(socket, socket.send, ['/app/chooseName', JSON.stringify({ playerName: username })]) +} + +function *validateUsername({ socket }) { + const usernameChannel = yield call(createSubscriptionChannel, socket, '/user/queue/nameChoice') + const user = yield take(usernameChannel) + yield put(actions.setCurrentPlayer(user)) + yield apply(usernameChannel, usernameChannel.close) + yield put(push('/games')) +} + +function *usernameChoiceSaga(wsConnection) { + // TODO: Run sendUsername in loop when we have the ability to cancel saga on route change + yield [ + call(sendUsername, wsConnection), + call(validateUsername, wsConnection), + ] +} + +export default usernameChoiceSaga diff --git a/frontend/src/sagas/usernameChoice.js b/frontend/src/sagas/usernameChoice.js deleted file mode 100644 index ad5b5341..00000000 --- a/frontend/src/sagas/usernameChoice.js +++ /dev/null @@ -1,41 +0,0 @@ -import { call, take, put } from 'redux-saga/effects' -import { eventChannel } from 'redux-saga' -import { push } from 'react-router-redux' -import { fromJS } from 'immutable' - -import { actions, types } from '../redux/players' - -function usernameValidationChannel(socket) { - return eventChannel(emitter => { - const receiveUsernameHandler = socket.subscribe('/user/queue/nameChoice', event => { - emitter(fromJS(JSON.parse(event.body))) - }) - return () => receiveUsernameHandler.unsubscribe() - }) -} - -function *usernameValidation({ socket }) { - const usernameChannel = yield call(usernameValidationChannel, socket) - const user = yield take(usernameChannel) - yield put(actions.setCurrentPlayer(user)) - usernameChannel.close() - yield put(push('/games')) -} - -function *sendUsername({ socket }) { - const { username } = yield take(types.REQUEST_CHOOSE_USERNAME) - - yield socket.send('/app/chooseName', JSON.stringify({ - playerName: username - })) -} - -function *usernameChoiceSaga(wsConnection) { - // TODO: Run sendUsername in loop when we have the ability to cancel saga on route change - yield [ - call(sendUsername, wsConnection), - call(usernameValidation, wsConnection), - ] -} - -export default usernameChoiceSaga diff --git a/frontend/src/store.js b/frontend/src/store.js index 2000d706..bd05e2c5 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -1,7 +1,7 @@ import { createStore, applyMiddleware, compose } from 'redux' import { browserHistory } from 'react-router' import { syncHistoryWithStore, routerMiddleware } from 'react-router-redux' -import { fromJS } from 'immutable' +import Immutable from 'seamless-immutable' import createReducer from './reducers' import createSagaMiddleware from 'redux-saga' @@ -28,7 +28,7 @@ export default function configureStore(initialState = {}) { const store = createStore( createReducer(), - fromJS(initialState), + Immutable.from(initialState), composeEnhancers(...enhancers) ) diff --git a/frontend/src/utils/createWebSocketConnection.js b/frontend/src/utils/createWebSocketConnection.js deleted file mode 100644 index f5d29f37..00000000 --- a/frontend/src/utils/createWebSocketConnection.js +++ /dev/null @@ -1,14 +0,0 @@ -import SockJS from 'sockjs-client' -import Stomp from 'webstomp-client' -const wsURL = '/seven-wonders-websocket' - -const createConnection = (headers = {}) => new Promise((resolve, reject) => { - let socket = Stomp.over(new SockJS(wsURL), { - debug: process.env.NODE_ENV !== "production" - }) - socket.connect(headers, (frame) => { - return resolve({ frame, socket }) - }, reject) -}) - -export default createConnection diff --git a/frontend/src/utils/websocket.js b/frontend/src/utils/websocket.js new file mode 100644 index 00000000..1dde5397 --- /dev/null +++ b/frontend/src/utils/websocket.js @@ -0,0 +1,23 @@ +import SockJS from 'sockjs-client' +import Stomp from 'webstomp-client' +import { eventChannel } from 'redux-saga' + +const wsURL = '/seven-wonders-websocket' + +export const createWsConnection = (headers = {}) => new Promise((resolve, reject) => { + let socket = Stomp.over(new SockJS(wsURL), { + debug: process.env.NODE_ENV !== "production" + }) + socket.connect(headers, (frame) => { + return resolve({ frame, socket }) + }, reject) +}) + +export const createSubscriptionChannel = (socket, path) => { + return eventChannel(emitter => { + const receiveUsernameHandler = socket.subscribe(path, event => { + emitter(JSON.parse(event.body)) + }) + return () => receiveUsernameHandler.unsubscribe() + }) +} -- cgit