From 2d00d92a747dc846481893999da9da7c91f5f41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sat, 25 Feb 2017 18:50:46 +0100 Subject: [PATCH 01/60] Update react environment libs --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 75c641f..58e519e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tfoosball", - "version": "2.0.0", + "version": "2.1.0", "private": true, "devDependencies": { "autoprefixer": "6.5.1", @@ -60,15 +60,15 @@ }, "dependencies": { "babel-polyfill": "^6.20.0", - "chart.js": "^2.2.2", + "chart.js": "^2.5.0", "md5": "^2.2.1", "promise-window": "^1.1.0", "randy": "^1.5.1", - "react": "^15.4.1", + "react": "^15.4.2", "react-bootstrap": "^0.30.7", - "react-dom": "^15.4.1", - "react-redux": "^4.4.6", - "react-router": "^2.8.1", + "react-dom": "^15.4.2", + "react-redux": "^5.0.3", + "react-router": "^3.0.2", "react-router-bootstrap": "^0.23.1", "redux": "^3.6.0", "redux-saga": "^0.14.3", From 339447a9400f495014cbb4d4a4a5786ad4434ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Tue, 28 Feb 2017 23:50:43 +0100 Subject: [PATCH 02/60] Initial team sagas unit tests --- src/shared/teams/teams.sagas.js | 4 ++-- src/test/auth.saga.test.js | 1 - src/test/teams.sagas.test.js | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 src/test/teams.sagas.test.js diff --git a/src/shared/teams/teams.sagas.js b/src/shared/teams/teams.sagas.js index 9fb6a11..0e42bcc 100644 --- a/src/shared/teams/teams.sagas.js +++ b/src/shared/teams/teams.sagas.js @@ -29,7 +29,7 @@ export function* handleSelectTeam() { } } -function* createTeam(action) { +export function* createTeam(action) { const url = api.urls.teamList(); const data = validateMember({ name: action.name, @@ -85,7 +85,7 @@ export function* initTeam() { return currentTeam; } -function* handleJoinTeam() { +export function* handleJoinTeam() { while (true) { const action = yield take(REQUEST_JOIN_TEAM); const url = api.urls.teamJoin(); diff --git a/src/test/auth.saga.test.js b/src/test/auth.saga.test.js index 620cd3c..c8a2d4f 100644 --- a/src/test/auth.saga.test.js +++ b/src/test/auth.saga.test.js @@ -7,7 +7,6 @@ import { clean, raiseError } from '../shared/notifier.actions'; import { authenticate, loginFlow, getOAuthErrorMsg, signIn, fetchProfile } from '../shared/auth.sagas'; import { fetchTeams, initTeam } from '../shared/teams/teams.sagas'; import { removeState } from '../persistence'; -import profile from '../assets/mocks/profile.json'; import { browserHistory } from 'react-router' diff --git a/src/test/teams.sagas.test.js b/src/test/teams.sagas.test.js new file mode 100644 index 0000000..2e17e06 --- /dev/null +++ b/src/test/teams.sagas.test.js @@ -0,0 +1,36 @@ +import { call, put, take, select, fork, cancel } from 'redux-saga/effects'; +import { requestCreateTeam } from '../shared/teams/teams.actions'; +import { teamCreationFlow, createTeam, fetchTeams } from '../shared/teams/teams.sagas'; +import { authenticate, fetchProfile } from '../shared/auth.sagas'; +import { browserHistory } from 'react-router'; + +describe('Team creation flow saga - success scenario', () => { + const iterator = teamCreationFlow(); + const action = requestCreateTeam('Team', 'Username'); + const team = { id: 1, name: 'Team', member_id: 7 }; + + it('should wait for REQUEST_CREATE_TEAM', () => { + expect(iterator.next().value).toEqual(take(action.type)); + }); + + it('should attempt authenticating user', () => { + const iter = iterator.next(action).value; + expect(iter).toEqual(call(authenticate)); + }); + + it('should invoke createTeam saga', () => { + expect(iterator.next().value).toEqual(call(createTeam, action)); + }); + + it('should fetch user\'s teams', () => { + expect(iterator.next().value).toEqual(call(fetchTeams)); + }); + + it('should fetch user profile', () => { + expect(iterator.next().value).toEqual(call(fetchProfile, team.id, team.member_id)); + }); + + it('should redirect to /match page', () => { + expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], '/match')); + }) +}); \ No newline at end of file From d0d700c7783c4e7aea45f40ef42c3f5c10d52413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Wed, 1 Mar 2017 16:55:40 +0100 Subject: [PATCH 03/60] UTs for team creation flow saga - success scenario --- src/test/teams.sagas.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/teams.sagas.test.js b/src/test/teams.sagas.test.js index 2e17e06..21b2c01 100644 --- a/src/test/teams.sagas.test.js +++ b/src/test/teams.sagas.test.js @@ -23,7 +23,7 @@ describe('Team creation flow saga - success scenario', () => { }); it('should fetch user\'s teams', () => { - expect(iterator.next().value).toEqual(call(fetchTeams)); + expect(iterator.next(team).value).toEqual(call(fetchTeams)); }); it('should fetch user profile', () => { From 99126821c7bebb3e8da04d08864cbc17e419dbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Wed, 1 Mar 2017 21:16:54 +0100 Subject: [PATCH 04/60] UTs: Increase coverage of auth & teams sagas --- src/homepage/root.reducer.js | 2 +- src/homepage/root.sagas.js | 2 +- src/profile/profile.reducer.js | 2 +- src/shared/{ => auth}/auth.actions.js | 0 src/shared/{ => auth}/auth.reducer.js | 2 +- src/shared/{ => auth}/auth.sagas.js | 49 ++--- src/shared/{ => auth}/auth.types.js | 0 src/shared/auth/auth.utils.js | 12 ++ src/shared/components/Header.js | 2 +- src/shared/teams/teams.reducer.js | 2 +- src/shared/teams/teams.sagas.js | 3 +- src/test/auth.saga.test.js | 252 +++++++++++++++++--------- src/test/teams.sagas.test.js | 82 ++++++++- 13 files changed, 284 insertions(+), 126 deletions(-) rename src/shared/{ => auth}/auth.actions.js (100%) rename src/shared/{ => auth}/auth.reducer.js (91%) rename src/shared/{ => auth}/auth.sagas.js (62%) rename src/shared/{ => auth}/auth.types.js (100%) create mode 100644 src/shared/auth/auth.utils.js diff --git a/src/homepage/root.reducer.js b/src/homepage/root.reducer.js index 5d000a3..0b82184 100644 --- a/src/homepage/root.reducer.js +++ b/src/homepage/root.reducer.js @@ -2,7 +2,7 @@ import users from '../users/users.reducer'; import notifications from '../shared/notifier.reducer'; import profile from '../profile/profile.reducer'; import tournaments from '../tournament/tournaments.reducer'; -import auth from '../shared/auth.reducer'; +import auth from '../shared/auth/auth.reducer'; import ranking from '../ranking/ranking.reducer'; import modal from '../shared/modal.reducer'; import matches from '../matches/matches.reducer'; diff --git a/src/homepage/root.sagas.js b/src/homepage/root.sagas.js index 5660a6c..ecf8356 100644 --- a/src/homepage/root.sagas.js +++ b/src/homepage/root.sagas.js @@ -1,4 +1,4 @@ -import { loginFlow } from '../shared/auth.sagas'; +import { loginFlow } from '../shared/auth/auth.sagas'; import { logger } from '../shared/logger.sagas'; import { routerSaga } from '../shared/routes.sagas'; import { publish, removeMatch } from '../matches/matches.sagas'; diff --git a/src/profile/profile.reducer.js b/src/profile/profile.reducer.js index ec8c50d..53164ba 100644 --- a/src/profile/profile.reducer.js +++ b/src/profile/profile.reducer.js @@ -1,5 +1,5 @@ import * as types from './profile.types'; -import * as authTypes from '../shared/auth.types'; +import * as authTypes from '../shared/auth/auth.types'; import * as MatchTypes from '../matches/match.types'; const matches = (state = { page: 1, totalPages: 1, list: [] }, action) => { diff --git a/src/shared/auth.actions.js b/src/shared/auth/auth.actions.js similarity index 100% rename from src/shared/auth.actions.js rename to src/shared/auth/auth.actions.js diff --git a/src/shared/auth.reducer.js b/src/shared/auth/auth.reducer.js similarity index 91% rename from src/shared/auth.reducer.js rename to src/shared/auth/auth.reducer.js index 47da476..dbf9913 100644 --- a/src/shared/auth.reducer.js +++ b/src/shared/auth/auth.reducer.js @@ -1,5 +1,5 @@ import * as types from './auth.types'; -import { REQUEST_SAVE_PROFILE, REQUEST_SAVE_MEMBER} from '../settings/settings.actions'; +import { REQUEST_SAVE_PROFILE, REQUEST_SAVE_MEMBER} from '../../settings/settings.actions'; const profile = (state = {}, action) => { switch (action.type) { diff --git a/src/shared/auth.sagas.js b/src/shared/auth/auth.sagas.js similarity index 62% rename from src/shared/auth.sagas.js rename to src/shared/auth/auth.sagas.js index 542fb63..b5cb70f 100644 --- a/src/shared/auth.sagas.js +++ b/src/shared/auth/auth.sagas.js @@ -2,24 +2,12 @@ import { take, call, put, fork, cancel, select } from 'redux-saga/effects'; import { browserHistory } from 'react-router' import { SIGN_IN, SIGN_OUT } from './auth.types'; import { setToken, setProfile, signedOut } from './auth.actions'; -import { raiseError, clean } from './notifier.actions'; -import { initTeam, fetchTeams } from './teams/teams.sagas'; -import { prepareWindow } from '../api/oauth'; -import api from '../api'; -import { removeState } from '../persistence'; - -export const getOAuthErrorMsg = (error) => { - switch(error) { - case 'blocked': - return 'Authentication window was blocked. Please, try again.'; - case 'closed': - return 'Authentication window was closed. Please, try again.'; - case 'failure': - return 'Failed to log in.'; - default: - return 'Failed to authenticate.'; - } -}; +import { raiseError, clean } from '../notifier.actions'; +import { initTeam, fetchTeams } from '../teams/teams.sagas'; +import { prepareWindow } from '../../api/oauth'; +import api from '../../api'; +import { removeState } from '../../persistence'; +import { getOAuthErrorMsg } from './auth.utils'; export function* authenticate() { const token = yield select(state => state.auth.token); @@ -51,32 +39,33 @@ export function* signIn() { // User is not assigned to any team and we were redirected to /welcome page return; } - try { - yield call(fetchProfile, currentTeam.id, currentTeam.member_id); - } catch (error) { + // try { + yield call(fetchProfile, currentTeam.id, currentTeam.member_id); + // } catch (error) { // TODO What if the entry belongs to the other user that was previously logged in? // yield call(removeTeamState); // yield chooseTeam(); // yield fetchProfile(); - console.error(error); - } + // console.error(error); + // } yield call([browserHistory, browserHistory.push], `/match`); } export function* loginFlow() { while (true) { const task = yield fork(signIn); + yield take(SIGN_OUT); + yield cancel(task); + const logout_url = api.urls.logout(); try { - yield take(SIGN_OUT); - yield cancel(task); - const logout_url = api.urls.logout(); yield call(api.requests.get, logout_url, null, 'Failed to sign out. Please try again.'); - yield put(signedOut()); - yield put(clean()); - yield call(removeState); - yield call([browserHistory, browserHistory.push], '/'); } catch (error) { yield put(raiseError(error)); + continue; } + yield put(signedOut()); + yield put(clean()); + yield call(removeState); + yield call([browserHistory, browserHistory.push], '/'); } } diff --git a/src/shared/auth.types.js b/src/shared/auth/auth.types.js similarity index 100% rename from src/shared/auth.types.js rename to src/shared/auth/auth.types.js diff --git a/src/shared/auth/auth.utils.js b/src/shared/auth/auth.utils.js new file mode 100644 index 0000000..9d109b0 --- /dev/null +++ b/src/shared/auth/auth.utils.js @@ -0,0 +1,12 @@ +export const getOAuthErrorMsg = (error) => { + switch(error) { + case 'blocked': + return 'Authentication window was blocked. Please, try again.'; + case 'closed': + return 'Authentication window was closed. Please, try again.'; + case 'failure': + return 'Failed to log in.'; + default: + return 'Failed to authenticate.'; + } +}; diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index d8221f9..3042f16 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -1,5 +1,5 @@ import React from 'react'; -import { signIn, signOut } from '../auth.actions'; +import { signIn, signOut } from '../auth/auth.actions'; import { selectTeam } from '../teams/teams.actions'; import { getSelectedTeam } from '../teams/teams.reducer'; import { Navbar, Nav, NavItem } from 'react-bootstrap'; diff --git a/src/shared/teams/teams.reducer.js b/src/shared/teams/teams.reducer.js index 3b12224..b7f6778 100644 --- a/src/shared/teams/teams.reducer.js +++ b/src/shared/teams/teams.reducer.js @@ -1,6 +1,6 @@ import { TEAM_CREATED, SET_TEAMS, SELECT_TEAM, PENDING_MEMBERS } from './teams.actions'; import { UPDATE_PROFILE } from '../../profile/profile.types'; -import { SIGNED_OUT } from '../auth.types'; +import { SIGNED_OUT } from '../auth/auth.types'; export const getSelectedTeam = (state) => state.joined.find(team => team.id === state.selected); export default (state = { joined: [], selected: 0, pending: [] }, action) => { diff --git a/src/shared/teams/teams.sagas.js b/src/shared/teams/teams.sagas.js index 0e42bcc..66c2aa7 100644 --- a/src/shared/teams/teams.sagas.js +++ b/src/shared/teams/teams.sagas.js @@ -11,7 +11,7 @@ import { } from './teams.actions.js'; import api from '../../api'; import { showInfo, raiseError } from '../notifier.actions'; -import { authenticate, fetchProfile } from '../auth.sagas'; +import { authenticate, fetchProfile } from '../auth/auth.sagas'; import { validateMember } from '../../settings/settings.sagas'; import { browserHistory } from 'react-router'; import { getSelectedTeam } from './teams.reducer'; @@ -81,7 +81,6 @@ export function* initTeam() { currentTeam = teamsState.joined[0]; } yield put(selectTeam(currentTeam)); - console.log('initTeam returns', currentTeam); return currentTeam; } diff --git a/src/test/auth.saga.test.js b/src/test/auth.saga.test.js index c8a2d4f..c3ec268 100644 --- a/src/test/auth.saga.test.js +++ b/src/test/auth.saga.test.js @@ -2,123 +2,151 @@ import { call, put, take, select, fork, cancel } from 'redux-saga/effects'; import { createMockTask } from 'redux-saga/utils'; import { prepareWindow } from '../api/oauth'; import api from '../api'; -import * as AuthActions from '../shared/auth.actions'; +import * as AuthActions from '../shared/auth/auth.actions'; import { clean, raiseError } from '../shared/notifier.actions'; -import { authenticate, loginFlow, getOAuthErrorMsg, signIn, fetchProfile } from '../shared/auth.sagas'; +import { authenticate, loginFlow, signIn, fetchProfile } from '../shared/auth/auth.sagas'; +import { getOAuthErrorMsg } from '../shared/auth/auth.utils'; import { fetchTeams, initTeam } from '../shared/teams/teams.sagas'; import { removeState } from '../persistence'; import { browserHistory } from 'react-router' -describe('Authenticate: OAuth window success scenario', () => { - const iterator = authenticate(); - const fixture = { token: 'some_token_value' }; +describe('Authenticate saga', () => { + describe('Scenario 1: OAuth window - success', () => { + const iterator = authenticate(); + const fixture = { token: 'some_token_value' }; - it('should check if token exists', () => { - const iter = iterator.next().value; - expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) - }); + it('should check if token exists', () => { + const iter = iterator.next().value; + expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) + }); - it('should yield an effect call([promptWindow, open])', () => { - const promptWindow = prepareWindow(); - const iter1 = iterator.next().value; - const call1 = call([promptWindow, promptWindow.open]); - expect(JSON.stringify(iter1)).toEqual(JSON.stringify(call1)); - }); + it('should yield an effect call([promptWindow, open])', () => { + const promptWindow = prepareWindow(); + const iter1 = iterator.next().value; + const call1 = call([promptWindow, promptWindow.open]); + expect(JSON.stringify(iter1)).toEqual(JSON.stringify(call1)); + }); - it('should yield an effect put(setToken(token))', () => { - expect(iterator.next(fixture).value).toEqual(put(AuthActions.setToken(fixture.token))); - }); + it('should yield an effect put(setToken(token))', () => { + expect(iterator.next(fixture).value).toEqual(put(AuthActions.setToken(fixture.token))); + }); - it('should return token and complete', () => { - const iter = iterator.next(fixture.token).value; - expect(iter).toEqual(fixture); - expect(iterator.next().done).toEqual(true); + it('should return token and complete', () => { + const iter = iterator.next(fixture.token).value; + expect(iter).toEqual(fixture); + expect(iterator.next().done).toEqual(true); + }); }); -}); -describe('Authenticate: OAuth window already authenticated', () => { - const iterator = authenticate(); - const fixture = { token: 'some_token_value' }; + describe('Scenario 2: OAuth window - already authenticated', () => { + const iterator = authenticate(); + const fixture = { token: 'some_token_value' }; - it('should check if token exists', () => { - const iter = iterator.next(fixture).value; - expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) - }); + it('should check if token exists', () => { + const iter = iterator.next(fixture).value; + expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) + }); - it('should return token and complete', () => { - const iter = iterator.next(fixture.token).value; - expect(iter).toEqual(fixture); - expect(iterator.next().done).toEqual(true); + it('should return token and complete', () => { + const iter = iterator.next(fixture.token).value; + expect(iter).toEqual(fixture); + expect(iterator.next().done).toEqual(true); + }); }); -}); -describe('Authenticate: OAuth window failure scenario', () => { - const iterator = authenticate(); - const fixture = { error: 'failure' }; + describe('Scenario 3: OAuth window - failure scenario', () => { + const iterator = authenticate(); + const fixture = { error: 'failure' }; - it('should check if token exists', () => { - const iter = iterator.next(fixture).value; - expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) - }); + it('should check if token exists', () => { + const iter = iterator.next(fixture).value; + expect(JSON.stringify(iter)).toEqual(JSON.stringify(select())) + }); - it('should yield an effect call([promptWindow, open])', () => { - const promptWindow = prepareWindow(); - const iter = iterator.next().value; - const called = call([promptWindow, promptWindow.open]); - expect(JSON.stringify(iter)).toEqual(JSON.stringify(called)); - }); + it('should yield an effect call([promptWindow, open])', () => { + const promptWindow = prepareWindow(); + const iter = iterator.next().value; + const called = call([promptWindow, promptWindow.open]); + expect(JSON.stringify(iter)).toEqual(JSON.stringify(called)); + }); - it('should yield an effect put(raiseError(errorMsg))', () => { - const errorMsg = getOAuthErrorMsg(fixture); - expect(iterator.throw(fixture).value).toEqual(put(raiseError(errorMsg))); - }); + it('should yield an effect put(raiseError(errorMsg))', () => { + const errorMsg = getOAuthErrorMsg(fixture); + expect(iterator.throw(fixture).value).toEqual(put(raiseError(errorMsg))); + }); - it('should complete', () => { - const iter = iterator.next('some-token').value; - expect(iter).toEqual({}); - expect(iterator.next().done).toEqual(true); + it('should complete', () => { + const iter = iterator.next('some-token').value; + expect(iter).toEqual({}); + expect(iterator.next().done).toEqual(true); + }); }); }); + describe('SignIn saga', () => { - const iterator = signIn(); - const fixtureTeam = { - id: 1, - member_id: 7, - }; - - it('should wait for action SIGN_IN', () => { - expect(iterator.next(AuthActions.signIn()).value).toEqual(take(AuthActions.signIn().type)); - }); + describe('Scenario 1: Typical [Success]', () => { + const iterator = signIn(); + const fixtureTeam = { + id: 1, + member_id: 7, + }; + + it('should wait for action SIGN_IN', () => { + expect(iterator.next(AuthActions.signIn()).value).toEqual(take(AuthActions.signIn().type)); + }); - it('should call Authenticate saga', () => { - expect(iterator.next().value).toEqual(call(authenticate)); - }); + it('should call Authenticate saga', () => { + expect(iterator.next().value).toEqual(call(authenticate)); + }); - it('should call FetchTeams saga', () => { - expect(iterator.next().value).toEqual(call(fetchTeams)); - }); + it('should call FetchTeams saga', () => { + expect(iterator.next().value).toEqual(call(fetchTeams)); + }); - it('should call InitTeam saga', () => { - expect(iterator.next().value).toEqual(call(initTeam)); - }); + it('should call InitTeam saga', () => { + expect(iterator.next().value).toEqual(call(initTeam)); + }); - it('should call FetchProfile saga', () => { - expect(iterator.next(fixtureTeam).value).toEqual(call(fetchProfile, fixtureTeam.id, fixtureTeam.member_id)); - }); + it('should call FetchProfile saga', () => { + expect(iterator.next(fixtureTeam).value).toEqual(call(fetchProfile, fixtureTeam.id, fixtureTeam.member_id)); + }); - it('should navigate to root and finish', () => { - expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], `/match`)); - expect(iterator.next().done).toEqual(true); + it('should navigate to root and finish', () => { + expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], `/match`)); + expect(iterator.next().done).toEqual(true); + }); }); + describe('Scenario 2: User not assigned to any team', () => { + const iterator = signIn(); + it('should wait for action SIGN_IN', () => { + expect(iterator.next(AuthActions.signIn()).value).toEqual(take(AuthActions.signIn().type)); + }); + + it('should call Authenticate saga', () => { + expect(iterator.next().value).toEqual(call(authenticate)); + }); + + it('should call FetchTeams saga', () => { + expect(iterator.next().value).toEqual(call(fetchTeams)); + }); + + it('should call InitTeam saga', () => { + expect(iterator.next().value).toEqual(call(initTeam)); + }); + + it('should return from saga', () => { + expect(iterator.next().done).toEqual(true); + }); + }); }); -describe('Login flow', () => { - const iterator = loginFlow(); - describe('success scenario', () => { +describe('LoginFlow saga', () => { + describe('Scenario 1: Typical success', () => { + const iterator = loginFlow(); let signInSaga; it('should fork SignIn saga', () => { signInSaga = fork(signIn); @@ -159,4 +187,60 @@ describe('Login flow', () => { expect(iterator.next().done).toEqual(false); // Fork signIn again }); }); -}); \ No newline at end of file + + describe('Scenario 2: Failed to sign out', () => { + const iterator = loginFlow(); + const error_msg = 'Failed to sign out. Please try again.'; + let signInSaga; + it('should fork SignIn saga', () => { + signInSaga = fork(signIn); + expect(iterator.next().value).toEqual(signInSaga); + }); + + it('should wait for SIGN_OUT action', () => { + expect(iterator.next(createMockTask()).value).toEqual(take(AuthActions.signOut().type)); + }); + + it('should cancel SignIn saga', () => { + expect(JSON.stringify(iterator.next().value)).toEqual(JSON.stringify(cancel(createMockTask()))); + }); + + it('should call API sign out', () => { + const logout_url = api.urls.logout(); + const expected = call(api.requests.get, logout_url, null, error_msg); + expect(iterator.next().value).toEqual(expected) + }); + + it('should put RAISE_ERROR', () => { + expect(iterator.throw(error_msg).value).toEqual(put(raiseError(error_msg))); + }); + + it('should restart the saga', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(false); + expect(iter.value).toEqual(fork(signIn)); + }) + }) +}); + +describe('Fetch profile saga', () => { + const team = {id: 1, member_id: 7}; + const iterator = fetchProfile(team.id, team.member_id); + const profile_url = api.urls.teamMemberEntity(team.id, team.member_id); + const profile = { username: 'Heniek' }; + + it('should fetch profile from API', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call(api.requests.get, profile_url, {}, 'Failed to load user profile')); + }); + + it('should set profile in store', () => { + const iter = iterator.next(profile).value; + expect(iter).toEqual(put(AuthActions.setProfile(profile))); + }); + + it('should return from saga', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(true); + }); +}); diff --git a/src/test/teams.sagas.test.js b/src/test/teams.sagas.test.js index 21b2c01..9e0353e 100644 --- a/src/test/teams.sagas.test.js +++ b/src/test/teams.sagas.test.js @@ -1,8 +1,20 @@ import { call, put, take, select, fork, cancel } from 'redux-saga/effects'; import { requestCreateTeam } from '../shared/teams/teams.actions'; -import { teamCreationFlow, createTeam, fetchTeams } from '../shared/teams/teams.sagas'; -import { authenticate, fetchProfile } from '../shared/auth.sagas'; +import { teamCreationFlow, createTeam, fetchTeams, handleSelectTeam } from '../shared/teams/teams.sagas'; +import { authenticate, fetchProfile } from '../shared/auth/auth.sagas'; import { browserHistory } from 'react-router'; +import api from '../api'; +import { showInfo, raiseError } from '../shared/notifier.actions'; +import { + REQUEST_CREATE_TEAM, + REQUEST_JOIN_TEAM, + SELECT_TEAM, + MEMBER_ACCEPTANCE, + teamCreated, + setTeams, + selectTeam, + setPendingMembers, +} from '../shared/teams/teams.actions.js'; describe('Team creation flow saga - success scenario', () => { const iterator = teamCreationFlow(); @@ -32,5 +44,67 @@ describe('Team creation flow saga - success scenario', () => { it('should redirect to /match page', () => { expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], '/match')); - }) -}); \ No newline at end of file + }); +}); + +describe('Create team saga - success scenario', () => { + const team = { + name: 'Team 0', + username: 'Zbyszek', + }; + const iterator = createTeam(team); + const url = api.urls.teamList(); + + it('should call api: POST request', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(call(api.requests.post, url, team, 'Team already exists')); + }); + + it('should put TEAM_CREATED', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(teamCreated(team))) + }); + + it('should put SHOW_INFO about created team', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(showInfo(`Team ${team.name} created.`))); + }); + + it('should SELECT_TEAM', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(selectTeam(team))); + }); + + it('should return from saga with team data', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(true); + }); +}); + +describe('Handle team selection', () => { + const iterator = handleSelectTeam(); + const team = { + id: 7, + member_id: 15, + }; + + it('should wait to take SELECT_TEAM', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(SELECT_TEAM)); + }); + + it('should fetch profile', () => { + const iter = iterator.next(selectTeam(team)).value; + expect(iter).toEqual(call(fetchProfile, team.id, team.member_id)); + }); + + it('should redirect to /match', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call([browserHistory, browserHistory.push], '/match')); + }); + + it('should not return from saga', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(false); + }); +}); From d4403919fcebd416985bb8c79e2a045ae21903ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Wed, 1 Mar 2017 23:15:36 +0100 Subject: [PATCH 05/60] UTs for teams sagas with coverage >75% --- src/shared/auth/auth.sagas.js | 8 +- src/shared/teams/teams.sagas.js | 47 ++-- src/test/teams.sagas.test.js | 421 +++++++++++++++++++++++++++++--- 3 files changed, 411 insertions(+), 65 deletions(-) diff --git a/src/shared/auth/auth.sagas.js b/src/shared/auth/auth.sagas.js index b5cb70f..a7a7305 100644 --- a/src/shared/auth/auth.sagas.js +++ b/src/shared/auth/auth.sagas.js @@ -26,8 +26,12 @@ export function* authenticate() { export function* fetchProfile(team_id, member_id) { const profile_url = api.urls.teamMemberEntity(team_id, member_id); - const profile = yield call(api.requests.get, profile_url, {}, 'Failed to load user profile'); - yield put(setProfile(profile)); + try { + const profile = yield call(api.requests.get, profile_url, {}, 'Failed to load user profile'); + yield put(setProfile(profile)); + } catch(error) { + yield put(raiseError(error)); + } } export function* signIn() { diff --git a/src/shared/teams/teams.sagas.js b/src/shared/teams/teams.sagas.js index 66c2aa7..dc7ce2f 100644 --- a/src/shared/teams/teams.sagas.js +++ b/src/shared/teams/teams.sagas.js @@ -16,16 +16,14 @@ import { validateMember } from '../../settings/settings.sagas'; import { browserHistory } from 'react-router'; import { getSelectedTeam } from './teams.reducer'; +export const stateTokenSelector = state => state.hasOwnProperty('auth') && state.auth.hasOwnProperty('token'); +export const stateTeamsSelector = state => state.hasOwnProperty('teams') ? state.teams : []; export function* handleSelectTeam() { while (true) { const { team } = yield take(SELECT_TEAM); - try { - yield call(fetchProfile, team.id, team.member_id); - yield call([browserHistory, browserHistory.push], '/match'); - } catch (error) { - yield put(raiseError(error)); - } + yield call(fetchProfile, team.id, team.member_id); + yield call([browserHistory, browserHistory.push], '/match'); } } @@ -35,7 +33,13 @@ export function* createTeam(action) { name: action.name, username: action.username, }); - const response = yield call(api.requests.post, url, data, 'Team already exists'); + let response = {}; + try { + response = yield call(api.requests.post, url, data, 'Team already exists'); + } catch (error) { + yield put(raiseError(error)); + return response; + } yield put(teamCreated(response)); yield put(showInfo(`Team ${action.name} created.`)); yield put(selectTeam(response)); @@ -46,20 +50,16 @@ export function* teamCreationFlow() { while (true) { const action = yield take(REQUEST_CREATE_TEAM); // TODO First validate form data - try { - yield call(authenticate); // TODO check if not authenticated within this generator itself - const team = yield call(createTeam, action); - yield call(fetchTeams); - yield call(fetchProfile, team.id, team.member_id); // TODO Should not get there if failed during any previous steps - yield call([browserHistory, browserHistory.push], '/match'); - } catch (error) { - yield put(raiseError(error)); - } + yield call(authenticate); // TODO check if not authenticated within this generator itself + const team = yield call(createTeam, action); + yield call(fetchTeams); + yield call(fetchProfile, team.id, team.member_id); // TODO Should not get there if failed during any previous steps + yield call([browserHistory, browserHistory.push], '/match'); } } export function* fetchTeams() { - const alreadyAuthenticated = yield select(state => !!state.auth.token); + const alreadyAuthenticated = yield select(stateTokenSelector); if (!alreadyAuthenticated) return; const url = api.urls.teamListJoined(); try { @@ -71,10 +71,10 @@ export function* fetchTeams() { } export function* initTeam() { - const teamsState = yield select(state => state.teams); + const teamsState = yield select(stateTeamsSelector); let currentTeam = getSelectedTeam(teamsState); if (teamsState.joined.length === 0) { - browserHistory.push('/welcome'); + yield call([browserHistory, browserHistory.push], '/welcome'); return; } if (!currentTeam) { @@ -89,8 +89,8 @@ export function* handleJoinTeam() { const action = yield take(REQUEST_JOIN_TEAM); const url = api.urls.teamJoin(); try { - const err_msg = 'Team doesn\'t exist or username already taken'; - const response = yield call(api.requests.post, url, action.data, err_msg); + const errorMsg = 'Team doesn\'t exist or username already taken'; + const response = yield call(api.requests.post, url, action.data, errorMsg); yield put(showInfo(response)); } catch(error) { yield put(raiseError(error)); @@ -99,11 +99,12 @@ export function* handleJoinTeam() { } export function* fetchPendingMembers() { - const teamsState = yield select(state => state.teams); + const errorMsg = 'Failed to fetch pending members'; + const teamsState = yield select(stateTeamsSelector); let currentTeam = getSelectedTeam(teamsState); const url = api.urls.teamMemberList(currentTeam.id); try { - const response = yield call(api.requests.get, url, { is_accepted: 'False' }, 'Failed to fetch pending members'); + const response = yield call(api.requests.get, url, { is_accepted: 'False' }, errorMsg); yield put(setPendingMembers(response)); } catch (error) { yield put(raiseError(error)); diff --git a/src/test/teams.sagas.test.js b/src/test/teams.sagas.test.js index 9e0353e..5eb30ad 100644 --- a/src/test/teams.sagas.test.js +++ b/src/test/teams.sagas.test.js @@ -1,7 +1,18 @@ import { call, put, take, select, fork, cancel } from 'redux-saga/effects'; import { requestCreateTeam } from '../shared/teams/teams.actions'; -import { teamCreationFlow, createTeam, fetchTeams, handleSelectTeam } from '../shared/teams/teams.sagas'; +import { + teamCreationFlow, + createTeam, + fetchTeams, + handleSelectTeam, + stateTokenSelector, + stateTeamsSelector, + initTeam, + handleJoinTeam, + fetchPendingMembers, +} from '../shared/teams/teams.sagas'; import { authenticate, fetchProfile } from '../shared/auth/auth.sagas'; +import { requestJoinTeam } from '../shared/teams/teams.actions'; import { browserHistory } from 'react-router'; import api from '../api'; import { showInfo, raiseError } from '../shared/notifier.actions'; @@ -16,72 +27,126 @@ import { setPendingMembers, } from '../shared/teams/teams.actions.js'; -describe('Team creation flow saga - success scenario', () => { - const iterator = teamCreationFlow(); - const action = requestCreateTeam('Team', 'Username'); - const team = { id: 1, name: 'Team', member_id: 7 }; - - it('should wait for REQUEST_CREATE_TEAM', () => { - expect(iterator.next().value).toEqual(take(action.type)); +describe('StateTokenSelector', () => { + it('should return true when token is present', () => { + const state = { auth: {token: 'abc123'}}; + expect(stateTokenSelector(state)).toBe(true); }); - it('should attempt authenticating user', () => { - const iter = iterator.next(action).value; - expect(iter).toEqual(call(authenticate)); + it('should return false when token is not present', () => { + const state = { auth: {}}; + expect(stateTokenSelector(state)).toBe(false); }); - it('should invoke createTeam saga', () => { - expect(iterator.next().value).toEqual(call(createTeam, action)); + it('should return false when auth is not present', () => { + const state = {}; + expect(stateTokenSelector(state)).toBe(false); }); +}); - it('should fetch user\'s teams', () => { - expect(iterator.next(team).value).toEqual(call(fetchTeams)); +describe('StateTeamsSelector', () => { + it('should return list of joined teams when possible', () => { + const state = { teams: { joined: [{id: 5}]}}; + expect(stateTeamsSelector(state)).toEqual(state.teams); }); - it('should fetch user profile', () => { - expect(iterator.next().value).toEqual(call(fetchProfile, team.id, team.member_id)); + it('should return empty list when teams is not present', () => { + const state = {}; + expect(stateTeamsSelector(state)).toEqual([]); }); +}); + +describe('TeamCreationFlow saga', () => { + describe('Scenario 1: Typical [Success]', () => { + const iterator = teamCreationFlow(); + const action = requestCreateTeam('Team', 'Username'); + const team = { id: 1, name: 'Team', member_id: 7 }; + + it('should wait for REQUEST_CREATE_TEAM', () => { + expect(iterator.next().value).toEqual(take(action.type)); + }); - it('should redirect to /match page', () => { - expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], '/match')); + it('should attempt authenticating user', () => { + const iter = iterator.next(action).value; + expect(iter).toEqual(call(authenticate)); + }); + + it('should invoke createTeam saga', () => { + expect(iterator.next().value).toEqual(call(createTeam, action)); + }); + + it('should fetch user\'s teams', () => { + expect(iterator.next(team).value).toEqual(call(fetchTeams)); + }); + + it('should fetch user profile', () => { + expect(iterator.next().value).toEqual(call(fetchProfile, team.id, team.member_id)); + }); + + it('should redirect to /match page', () => { + expect(iterator.next().value).toEqual(call([browserHistory, browserHistory.push], '/match')); + }); }); }); -describe('Create team saga - success scenario', () => { +describe('CreateTeam saga - success scenario', () => { const team = { name: 'Team 0', username: 'Zbyszek', }; - const iterator = createTeam(team); const url = api.urls.teamList(); - it('should call api: POST request', () => { - const iter = iterator.next(team).value; - expect(iter).toEqual(call(api.requests.post, url, team, 'Team already exists')); - }); + describe('Scenario 1: Typical [Success]', () => { + const iterator = createTeam(team); - it('should put TEAM_CREATED', () => { - const iter = iterator.next(team).value; - expect(iter).toEqual(put(teamCreated(team))) - }); + it('should call api: POST request', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(call(api.requests.post, url, team, 'Team already exists')); + }); - it('should put SHOW_INFO about created team', () => { - const iter = iterator.next(team).value; - expect(iter).toEqual(put(showInfo(`Team ${team.name} created.`))); - }); + it('should put TEAM_CREATED', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(teamCreated(team))) + }); + + it('should put SHOW_INFO about created team', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(showInfo(`Team ${team.name} created.`))); + }); - it('should SELECT_TEAM', () => { - const iter = iterator.next(team).value; - expect(iter).toEqual(put(selectTeam(team))); + it('should SELECT_TEAM', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(put(selectTeam(team))); + }); + + it('should return from saga with team data', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(true); + }); }); - it('should return from saga with team data', () => { - const iter = iterator.next(); - expect(iter.done).toEqual(true); + describe('Scenario 2: Failed to POST a new team', () => { + const iterator = createTeam(team); + const errorMsg = 'Team already exists'; + it('should call api: POST request', () => { + const iter = iterator.next(team).value; + expect(iter).toEqual(call(api.requests.post, url, team, errorMsg)); + }); + + it('should put RAISE_ERROR with error message', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should return from the saga with empty object', () => { + const iter = iterator.next(); + expect(iter.done).toEqual(true); + expect(iter.value).toEqual({}); + }); }); }); -describe('Handle team selection', () => { +describe('HandleSelectTeam saga', () => { const iterator = handleSelectTeam(); const team = { id: 7, @@ -106,5 +171,281 @@ describe('Handle team selection', () => { it('should not return from saga', () => { const iter = iterator.next(); expect(iter.done).toEqual(false); + expect(iter.value).toEqual(take(SELECT_TEAM)); + }); +}); + +describe('FetchTeams saga', () => { + const authenticatedStoreMock = { + auth: { + token: 'abc123', + }, + }; + const unauthenticatedStoreMock = {}; + const responseMock = { teams: [{id: 5}, {id: 7},], pending: 0 }; + const errorMsg = 'Failed to fetch user teams'; + + describe('Scenario 1: Success', () => { + const iterator = fetchTeams(); + + it('should check whether token is present in store', () => { + const iter = iterator.next().value; + expect(iter).toEqual(select(stateTokenSelector)); + }); + + it('should call API to fetch user teams', () => { + const iter = iterator.next(authenticatedStoreMock).value; + const url = api.urls.teamListJoined(); + expect(iter).toEqual(call(api.requests.get, url, {}, errorMsg)); + }); + + it('should put SET_TEAMS with returned team list', () => { + const iter = iterator.next(responseMock).value; + expect(iter).toEqual(put(setTeams(responseMock))) + }); + + it('should return from the saga', () => { + expect(iterator.next().done).toBe(true); + }); + }); + + describe('Scenario 2: Unauthenticated', () => { + const iterator = fetchTeams(); + + it('should check whether token is present in store', () => { + const iter = iterator.next(unauthenticatedStoreMock).value; + expect(iter).toEqual(select(stateTokenSelector)); + }); + + it('should call API to fetch user teams', () => { + const iter = iterator.next(); + expect(iter.value).toBe(undefined); + expect(iter.done).toBe(true); + }); + }); + + describe('Scenario 3: Fetch failed', () => { + const iterator = fetchTeams(); + + it('should check whether token is present in store', () => { + const iter = iterator.next().value; + expect(iter).toEqual(select(stateTokenSelector)); + }); + + it('should call API to fetch user teams', () => { + const iter = iterator.next(authenticatedStoreMock).value; + const url = api.urls.teamListJoined(); + expect(iter).toEqual(call(api.requests.get, url, {}, errorMsg)); + }); + + it('should put RAISE_ERROR with errorMsg', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should return from the saga', () => { + expect(iterator.next().done).toBe(true); + }); }); }); + +describe('InitTeam saga', () => { + const stateWithTeamsAndSelected = { + teams: { + joined: [ {id: 3}, {id: 5} ], + pending: 0, + selected: 5 + }, + }; + const stateWithTeams = { + teams: { + joined: [ {id: 3}, {id: 5} ], + pending: 0, + } + }; + + const stateWithoutTeams = { + teams: { + joined: [], + pending: 1, + } + }; + + describe('Scenario 1: Success - team selected', () => { + const iterator = initTeam(); + it('should get teams from store', () => { + const iter = iterator.next(stateWithTeamsAndSelected).value; + expect(iter).toEqual(select(stateTeamsSelector)); + }); + + it('should put SELECT_TEAM with the team', () => { + const iter = iterator.next(stateWithTeamsAndSelected.teams).value; + expect(iter).toEqual(put(selectTeam(stateWithTeamsAndSelected.teams.joined[1]))) + }); + + it('should return from the saga with the team selected', () => { + const iter = iterator.next(); + expect(iter.value).toEqual(stateWithTeamsAndSelected.teams.joined[1]); + expect(iter.done).toBe(true); + }); + }); + + describe('Scenario 2: No teams were joined', () => { + const iterator = initTeam(); + it('should get teams from store', () => { + const iter = iterator.next(stateWithoutTeams).value; + expect(iter).toEqual(select(stateTeamsSelector)); + }); + + it('should redirect to /welcome page', () => { + const iter = iterator.next(stateWithoutTeams.teams).value; + expect(iter).toEqual(call([browserHistory, browserHistory.push], '/welcome')); + }); + + it('should return from the saga', () => { + const iter = iterator.next(); + expect(iter.done).toBe(true); + }); + }); + + describe('Scenario 3: No team was selected previously', () => { + const iterator = initTeam(); + + it('should get teams from store', () => { + const iter = iterator.next(stateWithTeams).value; + expect(iter).toEqual(select(stateTeamsSelector)); + }); + + it('should put SELECT_TEAM with the team', () => { + const iter = iterator.next(stateWithTeams.teams).value; + expect(iter).toEqual(put(selectTeam(stateWithTeamsAndSelected.teams.joined[0]))) + }); + + it('should return from the saga with the team selected', () => { + const iter = iterator.next(); + expect(iter.value).toEqual(stateWithTeamsAndSelected.teams.joined[0]); + expect(iter.done).toBe(true); + }); + }) +}); + +describe('HandleJoinTeam saga', () => { + const url = api.urls.teamJoin(); + const errorMsg = 'Team doesn\'t exist or username already taken'; + const action = requestJoinTeam({id: 17}, 'Dodo'); + + describe('Scenario 1: Successful POST with join request', () => { + const iterator = handleJoinTeam(); + it('should wait for REQUEST_JOIN_TEAM', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_JOIN_TEAM)); + }); + + it('should call API with POST request to join team', () => { + const iter = iterator.next(action).value; + expect(iter).toEqual(call(api.requests.post, url, action.data, errorMsg)); + }); + + it('should put SHOW_INFO with success message', () => { + const response = 'OK'; + const iter = iterator.next(response).value; + expect(iter).toEqual(put(showInfo(response))); + }); + + it('should not return from the saga', () => { + const iter = iterator.next(); + expect(iter.done).toBe(false); + }); + }); + + describe('Scenario 2: POST with join request failed', () => { + const iterator = handleJoinTeam(); + it('should wait for REQUEST_JOIN_TEAM', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_JOIN_TEAM)); + }); + + it('should call API with POST request to join team', () => { + const iter = iterator.next(action).value; + expect(iter).toEqual(call(api.requests.post, url, action.data, errorMsg)); + }); + + it('should put RAISE_ERROR with error message', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should not return from the saga', () => { + const iter = iterator.next(); + expect(iter.done).toBe(false); + }); + }); +}); + +describe('FetchPendingMembers saga', () => { + const errorMsg = 'Failed to fetch pending members'; + const stateWithTeamsAndSelected = { + teams: { + joined: [ {id: 3}, {id: 5} ], + pending: 0, + selected: 5 + }, + }; + + describe('Scenario 1: Successful GET pending members', () => { + const iterator = fetchPendingMembers(); + const url = api.urls.teamMemberList(stateWithTeamsAndSelected.teams.selected); + + it('should select teams from store', () => { + const iter = iterator.next(stateWithTeamsAndSelected).value; + expect(iter).toEqual(select(stateTeamsSelector)); + }); + + it('should call API with GET request to fetch not accepted members', () => { + const iter = iterator.next(stateWithTeamsAndSelected.teams).value; + expect(iter).toEqual(call(api.requests.get, url, { is_accepted: 'False'}, errorMsg)); + }); + + it('should put SET_PENDING_MEMBERS with success message', () => { + const response = {}; + const iter = iterator.next(response).value; + expect(iter).toEqual(put(setPendingMembers(response))); + }); + + it('should return from the saga', () => { + const iter = iterator.next(); + expect(iter.done).toBe(true); + }); + }); + + describe('Scenario 2: Failed to get pending members list', () => { + const iterator = fetchPendingMembers(); + const url = api.urls.teamMemberList(stateWithTeamsAndSelected.teams.selected); + + it('should select teams from store', () => { + const iter = iterator.next(stateWithTeamsAndSelected).value; + expect(iter).toEqual(select(stateTeamsSelector)); + }); + + it('should call API with GET request to fetch not accepted members', () => { + const iter = iterator.next(stateWithTeamsAndSelected.teams).value; + expect(iter).toEqual(call(api.requests.get, url, { is_accepted: 'False'}, errorMsg)); + }); + + it('should put SET_PENDING_MEMBERS with success message', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should return from the saga', () => { + const iter = iterator.next(); + expect(iter.done).toBe(true); + }); + }); +}); + +// +// it('should ', () => { +// const iter = iterator.next().value; +// expect(iter).toEqual() +// }) From 505ab7193b9e9e9a2168abbd30fb58bc39869451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 2 Mar 2017 23:49:10 +0100 Subject: [PATCH 06/60] UTs for matches sagas --- src/matches/matches.sagas.js | 9 +- src/test/match.saga.test.js | 304 +++++++++++++++++++++-------------- 2 files changed, 185 insertions(+), 128 deletions(-) diff --git a/src/matches/matches.sagas.js b/src/matches/matches.sagas.js index b7465bb..d8dc8ce 100644 --- a/src/matches/matches.sagas.js +++ b/src/matches/matches.sagas.js @@ -6,11 +6,13 @@ import { removed } from './match.actions'; import { showInfo, raiseError } from '../shared/notifier.actions'; import { fetchUpdateUsers } from '../users/users.sagas'; +export const stateTeamsSelectedSelector = state => state.teams.selected; + export function* publish() { const success_msg = points => `Match successfully saved. Red: ${points}, Blue: ${-points}`; while (true) { const action = yield take(PUBLISH); - const currentTeamId = yield select(state => state.teams.selected); + const currentTeamId = yield select(stateTeamsSelectedSelector); const url = api.urls.teamMatchList(currentTeamId); try { const response = yield call(api.requests.post, url, action.match_data, 'Failed to send match to server'); @@ -27,20 +29,19 @@ export function* publish() { export function* removeMatch() { while (true) { const action = yield take(DELETE); - const currentTeamId = yield select(state => state.teams.selected); + const currentTeamId = yield select(stateTeamsSelectedSelector); const url = api.urls.teamMatchEntity(currentTeamId, action.id); try { yield call(api.requests['delete'], url); yield put(removed(action.id)); } catch (error) { - console.error(error); yield put(raiseError(error)); } } } export function* listMatches({page}) { - const currentTeamId = yield select(state => state.teams.selected); + const currentTeamId = yield select(stateTeamsSelectedSelector); const url = api.urls.teamMatchList(currentTeamId); try { const matches = yield call(api.requests.get, url, { page }); diff --git a/src/test/match.saga.test.js b/src/test/match.saga.test.js index 3addc0a..c8de867 100644 --- a/src/test/match.saga.test.js +++ b/src/test/match.saga.test.js @@ -3,149 +3,205 @@ import api from '../api'; import * as MatchActions from '../matches/match.actions'; import * as MatchTypes from '../matches/match.types'; import { raiseError, showInfo } from '../shared/notifier.actions'; -import { publish, removeMatch } from '../matches/matches.sagas'; +import { publish, removeMatch, listMatches, stateTeamsSelectedSelector } from '../matches/matches.sagas'; import { fetchUpdateUsers } from '../users/users.sagas'; -describe('Publish a match - success scenario', () => { - const iterator = publish(); - const matchData = { - red_def: 'agappe1', - red_att: 'barthy2', - blue_def: 'celine3', - blue_att: 'doughnut4', - red_score: 3, - blue_score: 10, - }; - const response = { - red_def: 'agappe1', - red_att: 'barthy2', - blue_def: 'celine3', - blue_att: 'doughnut4', - date: "2017-01-10T20:24:31.805366Z", - red_score: 3, - blue_score: 10, - points: -22, - }; - const callback = () => {}; - - it('should wait for PUBLISH action to be dispatched', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(MatchTypes.PUBLISH)); - }); - - it('should select team id', () => { - expect(JSON.stringify(iterator.next(MatchActions.publish(matchData, callback)).value)).toEqual(JSON.stringify(select(() => 1))); - }); - - it('should call api to publish match', () => { - const url = api.urls.teamMatchList(1); - const expected = call(api.requests.post, url, matchData, 'Failed to send match to server'); - const iter = iterator.next(1).value; - expect(iter).toEqual(expected); - }); - - it('should put an action with server response', () => { - expect(iterator.next(response).value).toEqual(put(MatchActions.sent(response))); - }); - - it('should display success message', () => { - const success_msg = points => `Match successfully saved. Red: ${points}, Blue: ${-points}`; - expect(iterator.next(response).value).toEqual(put(showInfo(success_msg(response.points)))); - }); - it('should call to refresh users', () => { - expect(iterator.next().value).toEqual(call(fetchUpdateUsers)); - }); - - it('should call the callback', () => { - expect(iterator.next().value).toEqual(call(callback)); +describe('Publish match saga', () => { + describe('Scenario 1: Success', () => { + const iterator = publish(); + const matchData = { + red_def: 'agappe1', + red_att: 'barthy2', + blue_def: 'celine3', + blue_att: 'doughnut4', + red_score: 3, + blue_score: 10, + }; + const response = { + red_def: 'agappe1', + red_att: 'barthy2', + blue_def: 'celine3', + blue_att: 'doughnut4', + date: "2017-01-10T20:24:31.805366Z", + red_score: 3, + blue_score: 10, + points: -22, + }; + const callback = () => {}; + + it('should wait for PUBLISH action to be dispatched', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(MatchTypes.PUBLISH)); + }); + + it('should select team id', () => { + expect(JSON.stringify(iterator.next(MatchActions.publish(matchData, callback)).value)).toEqual(JSON.stringify(select(() => 1))); + }); + + it('should call api to publish match', () => { + const url = api.urls.teamMatchList(1); + const expected = call(api.requests.post, url, matchData, 'Failed to send match to server'); + const iter = iterator.next(1).value; + expect(iter).toEqual(expected); + }); + + it('should put an action with server response', () => { + expect(iterator.next(response).value).toEqual(put(MatchActions.sent(response))); + }); + + it('should display success message', () => { + const success_msg = points => `Match successfully saved. Red: ${points}, Blue: ${-points}`; + expect(iterator.next(response).value).toEqual(put(showInfo(success_msg(response.points)))); + }); + + it('should call to refresh users', () => { + expect(iterator.next().value).toEqual(call(fetchUpdateUsers)); + }); + + it('should call the callback', () => { + expect(iterator.next().value).toEqual(call(callback)); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); + + describe('Scenario 2: API failure', () => { + const iterator = publish(); + const matchData = { + red_def: 'agappe1', + red_att: 'barthy2', + blue_def: 'celine3', + blue_att: 'doughnut4', + red_score: 3, + blue_score: 10, + }; + const currentTeamId = 1; + const callback = () => {}; + + it('should wait for PUBLISH action to be dispatched', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(MatchTypes.PUBLISH)); + }); + + it('should select team id', () => { + const iter = JSON.stringify(iterator.next(MatchActions.publish(matchData, callback)).value); + expect(iter).toEqual(JSON.stringify(select(() => currentTeamId))); + }); + + it('should call api to publish match', () => { + const url = api.urls.teamMatchList(currentTeamId); + const expected = call(api.requests.post, url, matchData, 'Failed to send match to server'); + const iter = iterator.next(currentTeamId).value; + expect(iter).toEqual(expected); + }); + + it('should put ERROR when API fails', () => { + const error_msg = 'Failed to send match to server'; + expect(iterator.throw(error_msg).value).toEqual(put(raiseError(error_msg))); + }); }); }); -describe('Publish a match - API failure scenario', () => { - const iterator = publish(); - const matchData = { - red_def: 'agappe1', - red_att: 'barthy2', - blue_def: 'celine3', - blue_att: 'doughnut4', - red_score: 3, - blue_score: 10, - }; - const currentTeamId = 1; - const callback = () => {}; - - it('should wait for PUBLISH action to be dispatched', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(MatchTypes.PUBLISH)); - }); - - it('should select team id', () => { - const iter = JSON.stringify(iterator.next(MatchActions.publish(matchData, callback)).value); - expect(iter).toEqual(JSON.stringify(select(() => currentTeamId))); - }); - it('should call api to publish match', () => { - const url = api.urls.teamMatchList(currentTeamId); - const expected = call(api.requests.post, url, matchData, 'Failed to send match to server'); - const iter = iterator.next(currentTeamId).value; - expect(iter).toEqual(expected); - }); +describe('RemoveMatch saga', () => { + describe('Scenario 1: Success', () => { + const iterator = removeMatch(); + const matchID = 0; - it('should put ERROR when API fails', () => { - const error_msg = 'Failed to send match to server'; - expect(iterator.throw(error_msg).value).toEqual(put(raiseError(error_msg))); - }); -}); + it('should wait for DELETE action to be dispatched', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(MatchTypes.DELETE)); + }); -describe('Remove a match - success scenario', () => { - const iterator = removeMatch(); - const matchID = 0; + it('should select team id', () => { + const iter = JSON.stringify(iterator.next(MatchActions.remove(matchID)).value); + expect(iter).toEqual(JSON.stringify(select(() => 1))); + }); - it('should wait for DELETE action to be dispatched', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(MatchTypes.DELETE)); - }); + it('should call API to remove match', () => { + const iter = iterator.next(1).value; + const url = api.urls.teamMatchEntity(1, matchID); + expect(iter).toEqual(call(api.requests['delete'], url)); + }); - it('should select team id', () => { - const iter = JSON.stringify(iterator.next(MatchActions.remove(matchID)).value); - expect(iter).toEqual(JSON.stringify(select(() => 1))); - }); + it('should put action match DELETED', () => { + const iter = iterator.next().value; + expect(iter).toEqual(put(MatchActions.removed(matchID))); + }); - it('should call API to remove match', () => { - const iter = iterator.next(1).value; - const url = api.urls.teamMatchEntity(1, matchID); - expect(iter).toEqual(call(api.requests['delete'], url)); + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); }); - it('should put action match DELETED', () => { - const iter = iterator.next().value; - expect(iter).toEqual(put(MatchActions.removed(matchID))); - }); -}); + describe('Scenario 2: API failure', () => { + const iterator = removeMatch(); + const matchID = 0; -describe('Remove a match - failure scenario', () => { - const iterator = removeMatch(); - const matchID = 0; + it('should wait for DELETE action to be dispatched', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(MatchTypes.DELETE)); + }); - it('should wait for DELETE action to be dispatched', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(MatchTypes.DELETE)); - }); + it('should select team id', () => { + const iter = JSON.stringify(iterator.next(MatchActions.remove(matchID)).value); + expect(iter).toEqual(JSON.stringify(select(() => 1))); + }); - it('should select team id', () => { - const iter = JSON.stringify(iterator.next(MatchActions.remove(matchID)).value); - expect(iter).toEqual(JSON.stringify(select(() => 1))); - }); + it('should call API to remove match', () => { + const iter = iterator.next(1).value; + const url = api.urls.teamMatchEntity(1, matchID); + expect(iter).toEqual(call(api.requests['delete'], url)); + }); - it('should call API to remove match', () => { - const iter = iterator.next(1).value; - const url = api.urls.teamMatchEntity(1, matchID); - expect(iter).toEqual(call(api.requests['delete'], url)); + it('should handle API response error', () => { + const iter = iterator.throw(`Failed to delete match of id#${matchID}`).value; + expect(iter).toEqual(put(raiseError(`Failed to delete match of id#${matchID}`))); + }); }); +}); - it('should handle API response error', () => { - const iter = iterator.throw(`Failed to delete match of id#${matchID}`).value; - expect(iter).toEqual(put(raiseError(`Failed to delete match of id#${matchID}`))); +describe('ListMatches saga', () => { + const params = {page: 7}; + const currentTeamId = 17; + const url = api.urls.teamMatchList(currentTeamId); + const matches = [{id: 15}, {id: 32}]; + const errorMsg = 'Failed to retrieve a list of matches.'; + + describe('Scenario 1: Success', () => { + const iterator = listMatches(params); + + it('should select team selected from store', () => { + expect(iterator.next(currentTeamId).value).toEqual(select(stateTeamsSelectedSelector)); + }); + it('should call fetch data from API', () => { + expect(iterator.next(currentTeamId).value).toEqual(call(api.requests.get, url, params)); + }); + it('should put matches to store', () => { + expect(iterator.next(matches).value).toEqual(put(MatchActions.list(matches))); + }); + it('should return from saga', () => { + expect(iterator.next().done).toBe(true); + }) + }); + + describe('Scenario 2: Failure', () => { + const iterator = listMatches(params); + + it('should select team selected from store', () => { + expect(iterator.next(currentTeamId).value).toEqual(select(stateTeamsSelectedSelector)); + }); + it('should call fetch data from API', () => { + expect(iterator.next(currentTeamId).value).toEqual(call(api.requests.get, url, params)); + }); + it('should display message on failure', () => { + expect(iterator.throw(errorMsg).value).toEqual(put(raiseError(errorMsg))); + }); + it('should return from saga', () => { + expect(iterator.next().done).toBe(true); + }) }); }); From ba35a534ec7dd3b01467b82ba96cc84c8c2a6dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sat, 4 Mar 2017 12:44:47 +0100 Subject: [PATCH 07/60] UTs for settings. Refactor getting current team. --- src/settings/settings.sagas.js | 12 +- src/shared/teams/teams.sagas.js | 11 +- .../{auth.saga.test.js => auth.sagas.test.js} | 0 ...match.saga.test.js => match.sagas.test.js} | 0 src/test/settings.sagas.test.js | 182 ++++++++++++++++++ src/test/teams.sagas.test.js | 13 +- ...users.saga.test.js => users.sagas.test.js} | 0 7 files changed, 201 insertions(+), 17 deletions(-) rename src/test/{auth.saga.test.js => auth.sagas.test.js} (100%) rename src/test/{match.saga.test.js => match.sagas.test.js} (100%) create mode 100644 src/test/settings.sagas.test.js rename src/test/{users.saga.test.js => users.sagas.test.js} (100%) diff --git a/src/settings/settings.sagas.js b/src/settings/settings.sagas.js index b7a5d34..7b013f5 100644 --- a/src/settings/settings.sagas.js +++ b/src/settings/settings.sagas.js @@ -1,9 +1,8 @@ -import { call, take, put, select } from 'redux-saga/effects'; +import { call, take, put } from 'redux-saga/effects'; import api from '../api'; import { REQUEST_SAVE_MEMBER, REQUEST_SAVE_PROFILE } from './settings.actions'; import { showInfo, raiseError } from '../shared/notifier.actions'; -import { getSelectedTeam } from '../shared/teams/teams.reducer'; -import { fetchPendingMembers } from '../shared/teams/teams.sagas'; +import { fetchPendingMembers, getCurrentTeam } from '../shared/teams/teams.sagas'; // TODO move it somewhere export const validateMember = (data) => { @@ -16,7 +15,7 @@ export const validateMember = (data) => { return data; }; -function* saveProfile() { +export function* saveProfile() { const url = api.urls.profile(); while (true) { const action = yield take(REQUEST_SAVE_PROFILE); @@ -29,11 +28,10 @@ function* saveProfile() { } } -function* saveMember() { +export function* saveMember() { while (true) { const action = yield take(REQUEST_SAVE_MEMBER); - const teamsState = yield select(state => state.teams); - const currentTeam = getSelectedTeam(teamsState); + const currentTeam = yield call(getCurrentTeam); const url = api.urls.teamMemberEntity(currentTeam.id, currentTeam.member_id); try { const data = validateMember(action.partialData); diff --git a/src/shared/teams/teams.sagas.js b/src/shared/teams/teams.sagas.js index dc7ce2f..d782a03 100644 --- a/src/shared/teams/teams.sagas.js +++ b/src/shared/teams/teams.sagas.js @@ -19,6 +19,11 @@ import { getSelectedTeam } from './teams.reducer'; export const stateTokenSelector = state => state.hasOwnProperty('auth') && state.auth.hasOwnProperty('token'); export const stateTeamsSelector = state => state.hasOwnProperty('teams') ? state.teams : []; +export function* getCurrentTeam() { + const teamsState = yield select(stateTeamsSelector); + return getSelectedTeam(teamsState); +} + export function* handleSelectTeam() { while (true) { const { team } = yield take(SELECT_TEAM); @@ -100,8 +105,7 @@ export function* handleJoinTeam() { export function* fetchPendingMembers() { const errorMsg = 'Failed to fetch pending members'; - const teamsState = yield select(stateTeamsSelector); - let currentTeam = getSelectedTeam(teamsState); + const currentTeam = yield call(getCurrentTeam); const url = api.urls.teamMemberList(currentTeam.id); try { const response = yield call(api.requests.get, url, { is_accepted: 'False' }, errorMsg); @@ -114,8 +118,7 @@ export function* fetchPendingMembers() { export function* memberAcceptance() { while (true) { const action = yield take(MEMBER_ACCEPTANCE); - const teamsState = yield select(state => state.teams); - let currentTeam = getSelectedTeam(teamsState); + const currentTeam = yield call(getCurrentTeam); const url = api.urls.teamMemberEntity(currentTeam.id, action.id); try { if (action.shouldAccept) { diff --git a/src/test/auth.saga.test.js b/src/test/auth.sagas.test.js similarity index 100% rename from src/test/auth.saga.test.js rename to src/test/auth.sagas.test.js diff --git a/src/test/match.saga.test.js b/src/test/match.sagas.test.js similarity index 100% rename from src/test/match.saga.test.js rename to src/test/match.sagas.test.js diff --git a/src/test/settings.sagas.test.js b/src/test/settings.sagas.test.js new file mode 100644 index 0000000..bccefdf --- /dev/null +++ b/src/test/settings.sagas.test.js @@ -0,0 +1,182 @@ +import { call, put, take } from 'redux-saga/effects'; +import api from '../api'; +import { raiseError, showInfo } from '../shared/notifier.actions'; +import { saveProfile, saveMember, validateMember, settings } from '../settings/settings.sagas'; +import { getCurrentTeam, fetchPendingMembers } from '../shared/teams/teams.sagas'; + +import { + REQUEST_SAVE_PROFILE, + REQUEST_SAVE_MEMBER, + requestSaveProfile, + requestSaveMember, +} from '../settings/settings.actions'; + + +describe('SaveProfile saga', () => { + const url = api.urls.profile(); + const errorMsg = 'Failed to save profile.'; + + describe('Scenario 1: User profile saved', () => { + const iterator = saveProfile(); + + it('should wait to take REQUEST_SAVE_PROFILE', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_SAVE_PROFILE)); + }); + + it('should call API with PATCH request to save profile', () => { + const action = requestSaveProfile({ first_name: 'Abc', last_name: '123' }); + const iter = iterator.next(action).value; + expect(iter).toEqual(call(api.requests.patch, url, action.partialData, errorMsg)); + }); + + it('should put SHOW_INFO that profile was saved', () => { + const iter = iterator.next().value; + expect(iter).toEqual(put(showInfo('Profile changes saved.'))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); + + describe('Scenario 2: Failed to save user profile', () => { + const iterator = saveProfile(); + + it('should wait to take REQUEST_SAVE_PROFILE', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_SAVE_PROFILE)); + }); + + it('should call API with PATCH request to save profile', () => { + const action = requestSaveProfile({ first_name: 'Abc', last_name: '123' }); + const iter = iterator.next(action).value; + expect(iter).toEqual(call(api.requests.patch, url, action.partialData, errorMsg)); + }); + + it('should put RAISE_ERROR that profile was not saved', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); + +}); + + +describe('SaveMember saga', () => { + const currentTeam = { + id: 1, + member_id: 15, + }; + const url = api.urls.teamMemberEntity(currentTeam.id, currentTeam.member_id); + const successMsg = 'Team member profile saved.'; + const errorMsg = 'Failed to save team member.'; + + describe('Scenario 1: Member profile saved', () => { + const iterator = saveMember(); + const action = requestSaveMember({ username: 'Audiomatic' }); + + it('should wait to take REQUEST_SAVE_MEMBER', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_SAVE_MEMBER)); + }); + + it('should get current team', () => { + expect(iterator.next(action).value).toEqual(call(getCurrentTeam)); + }); + + it('should call API with PATCH request to save profile', () => { + const iter = iterator.next(currentTeam).value; + const data = validateMember(action.partialData); + expect(iter).toEqual(call(api.requests.patch, url, data, errorMsg)); + }); + + it('should put SHOW_INFO that profile was saved', () => { + const iter = iterator.next().value; + expect(iter).toEqual(put(showInfo(successMsg))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); + + + + describe('Scenario 2: Failed to save member profile', () => { + const iterator = saveMember(); + const action = requestSaveMember({ username: 'Audiomatic' }); + + it('should wait to take REQUEST_SAVE_MEMBER', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(REQUEST_SAVE_MEMBER)); + }); + + it('should get current team', () => { + expect(iterator.next(action).value).toEqual(call(getCurrentTeam)); + }); + + it('should call API with PATCH request to save profile', () => { + const iter = iterator.next(currentTeam).value; + const data = validateMember(action.partialData); + expect(iter).toEqual(call(api.requests.patch, url, data, errorMsg)); + }); + + it('should put RAISE_ERROR that the profile was NOT saved', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); +}); + +describe('Validate member profile data', () => { + it('should return data when username has length >=3', () => { + const validData = { + username: 'exe', + }; + expect(() => validateMember(validData)).not.toThrowError(); + expect(validateMember(validData)).toEqual(validData); + }); + + it('should return data when username has length <= 14', () => { + const validData = { + username: 'executive12345', + }; + expect(() => validateMember(validData)).not.toThrowError(); + expect(validateMember(validData)).toEqual(validData); + }); + + it('should throw error when username consists of more than 14 characters', () => { + const invalidData = { + username: 'anatomopatomorfolog', + }; + expect(() => validateMember(invalidData)).toThrowError(); + }); + + it('should throw error when username consists of no more than 3 characters', () => { + const invalidData = { + username: 'dx', + }; + expect(() => validateMember(invalidData)).toThrowError(); + }); +}); + +describe('Test settings route saga', () => { + const iterator = settings(); + it('should yield all sagas that run on the route', () => { + const exp = [ + saveProfile(), + saveMember(), + fetchPendingMembers(), + ]; + expect(JSON.stringify(iterator.next().value)).toEqual(JSON.stringify(exp)); + }); +}); diff --git a/src/test/teams.sagas.test.js b/src/test/teams.sagas.test.js index 5eb30ad..4ee7d94 100644 --- a/src/test/teams.sagas.test.js +++ b/src/test/teams.sagas.test.js @@ -10,6 +10,7 @@ import { initTeam, handleJoinTeam, fetchPendingMembers, + getCurrentTeam } from '../shared/teams/teams.sagas'; import { authenticate, fetchProfile } from '../shared/auth/auth.sagas'; import { requestJoinTeam } from '../shared/teams/teams.actions'; @@ -396,13 +397,13 @@ describe('FetchPendingMembers saga', () => { const iterator = fetchPendingMembers(); const url = api.urls.teamMemberList(stateWithTeamsAndSelected.teams.selected); - it('should select teams from store', () => { + it('should get current team', () => { const iter = iterator.next(stateWithTeamsAndSelected).value; - expect(iter).toEqual(select(stateTeamsSelector)); + expect(iter).toEqual(call(getCurrentTeam)); }); it('should call API with GET request to fetch not accepted members', () => { - const iter = iterator.next(stateWithTeamsAndSelected.teams).value; + const iter = iterator.next(stateWithTeamsAndSelected.teams.joined[1]).value; expect(iter).toEqual(call(api.requests.get, url, { is_accepted: 'False'}, errorMsg)); }); @@ -422,13 +423,13 @@ describe('FetchPendingMembers saga', () => { const iterator = fetchPendingMembers(); const url = api.urls.teamMemberList(stateWithTeamsAndSelected.teams.selected); - it('should select teams from store', () => { + it('should get current team', () => { const iter = iterator.next(stateWithTeamsAndSelected).value; - expect(iter).toEqual(select(stateTeamsSelector)); + expect(iter).toEqual(call(getCurrentTeam)); }); it('should call API with GET request to fetch not accepted members', () => { - const iter = iterator.next(stateWithTeamsAndSelected.teams).value; + const iter = iterator.next(stateWithTeamsAndSelected.teams.joined[1]).value; expect(iter).toEqual(call(api.requests.get, url, { is_accepted: 'False'}, errorMsg)); }); diff --git a/src/test/users.saga.test.js b/src/test/users.sagas.test.js similarity index 100% rename from src/test/users.saga.test.js rename to src/test/users.sagas.test.js From 1e07f234d1bb930d438c0651efaf4ebc2d6d7bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sat, 4 Mar 2017 13:05:54 +0100 Subject: [PATCH 08/60] Unit tests for play sagas --- src/play/play.sagas.js | 21 +++---- src/test/play.sagas.test.js | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 src/test/play.sagas.test.js diff --git a/src/play/play.sagas.js b/src/play/play.sagas.js index 675ce93..15a7911 100644 --- a/src/play/play.sagas.js +++ b/src/play/play.sagas.js @@ -1,22 +1,23 @@ import { call, take, put, select } from 'redux-saga/effects'; import api from '../api'; +import { getCurrentTeam } from '../shared/teams/teams.sagas'; import { CHOOSE } from '../users/user.types'; import { raiseError } from '../shared/notifier.actions'; import { requestStatsDone } from './play.actions'; +export const stateUsersPlayingSelector = ({users}) => users + .filter(u => u.playing) + .reduce( + (data, player) => Object.assign(data, {[`${player.team}_${player.position}`]: player.id}), + {} + ); + export function* playScore() { while (true) { yield take(CHOOSE); - const players = yield select( - ({users}) => users - .filter(u => u.playing) - .reduce( - (data, player) => Object.assign(data, {[`${player.team}_${player.position}`]: player.id}), - {} - ) - ); - const currentTeamId = yield select(state => state.teams.selected); - const url = api.urls.teamMatchPoints(currentTeamId); + const players = yield select(stateUsersPlayingSelector); + const currentTeam = yield call(getCurrentTeam); + const url = api.urls.teamMatchPoints(currentTeam.id); try { const response = yield call(api.requests.get, url, players, 'Unable to get match score statistics.'); yield put(requestStatsDone(response)); diff --git a/src/test/play.sagas.test.js b/src/test/play.sagas.test.js new file mode 100644 index 0000000..150c490 --- /dev/null +++ b/src/test/play.sagas.test.js @@ -0,0 +1,108 @@ +import { call, put, take, select } from 'redux-saga/effects'; +import api from '../api'; +import { raiseError, showInfo } from '../shared/notifier.actions'; +import { stateUsersPlayingSelector, playScore } from '../play/play.sagas'; +import { CHOOSE } from '../users/user.types'; +import { getCurrentTeam } from '../shared/teams/teams.sagas'; +import { requestStatsDone } from '../play/play.actions'; + +describe('PlayScore saga', () => { + const currentTeam = { + id: 17, + }; + const url = api.urls.teamMatchPoints(currentTeam.id); + const errorMsg = 'Unable to get match score statistics.'; + const players = { + red_att: {id: 1}, + red_def: {id: 2}, + blue_def: {id: 3}, + blue_att: {id: 4}, + }; + const response = { + blue: 21, + red: 21, + }; + + describe('Scenario 1: Successfully obtained play score', () => { + const iterator = playScore(); + it('should wait to take CHOOSE', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(CHOOSE)); + }); + + it('should select playing users', () => { + const iter = iterator.next().value; + expect(iter).toEqual(select(stateUsersPlayingSelector)); + }); + + it('should obtain current team', () => { + const iter = iterator.next(players).value; + expect(iter).toEqual(call(getCurrentTeam)); + }); + + it('should make GET request to team match points', () => { + const iter = iterator.next(currentTeam).value; + expect(iter).toEqual(call(api.requests.get, url, players, errorMsg)); + }); + + it('should put action with score', () => { + const iter = iterator.next(response).value; + expect(iter).toEqual(put(requestStatsDone(response))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); + + describe('Scenario 2: Failed to obtain play score from API', () => { + const iterator = playScore(); + it('should wait to take CHOOSE', () => { + const iter = iterator.next().value; + expect(iter).toEqual(take(CHOOSE)); + }); + + it('should select playing users', () => { + const iter = iterator.next().value; + expect(iter).toEqual(select(stateUsersPlayingSelector)); + }); + + it('should obtain current team', () => { + const iter = iterator.next(players).value; + expect(iter).toEqual(call(getCurrentTeam)); + }); + + it('should make GET request to team match points', () => { + const iter = iterator.next(currentTeam).value; + expect(iter).toEqual(call(api.requests.get, url, players, errorMsg)); + }); + + it('should put RAISE_ERROR with errorMsg', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should not return from saga', () => { + expect(iterator.next().done).toBe(false); + }); + }); +}); + +describe('Playing users selector', () => { + const state = { + users: [ + { id: 1, team: 'red', position: 'att', playing: true, }, + { id: 2, }, + { id: 3, team: 'red', position: 'def', playing: true, }, + { id: 4, team: 'blue', position: 'att', playing: true, }, + { id: 5, team: 'blue', position: 'def', playing: true, }, + ] + }; + const expectedData = { + red_att: 1, + red_def: 3, + blue_att: 4, + blue_def: 5, + }; + expect(stateUsersPlayingSelector(state)).toEqual(expectedData); +}); \ No newline at end of file From ec411a2db77dfed9b15a8f86812e95273172d4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sat, 4 Mar 2017 18:04:22 +0100 Subject: [PATCH 09/60] Unit tests for users reducers. Fix immutability. --- src/shared/auth/auth.reducer.js | 9 +- src/test/auth.reducer.test.js | 195 ++++++++++++++ src/test/users.reducer.test.js | 376 ++++++++++++++++++++++++--- src/users/components/MatchToolbar.js | 25 +- src/users/users.reducer.js | 14 +- 5 files changed, 571 insertions(+), 48 deletions(-) create mode 100644 src/test/auth.reducer.test.js diff --git a/src/shared/auth/auth.reducer.js b/src/shared/auth/auth.reducer.js index dbf9913..a52968e 100644 --- a/src/shared/auth/auth.reducer.js +++ b/src/shared/auth/auth.reducer.js @@ -1,17 +1,17 @@ import * as types from './auth.types'; import { REQUEST_SAVE_PROFILE, REQUEST_SAVE_MEMBER} from '../../settings/settings.actions'; -const profile = (state = {}, action) => { +export const profile = (state = {}, action) => { switch (action.type) { case REQUEST_SAVE_PROFILE: case REQUEST_SAVE_MEMBER: - return Object.assign(state, action.partialData); + return Object.assign({}, state, action.partialData); default: return state; } }; -const auth = (state = {}, action) => { +export const auth = (state = {}, action) => { switch (action.type) { case types.SET_TOKEN: return { @@ -35,4 +35,5 @@ const auth = (state = {}, action) => { return state; } }; -export default auth; \ No newline at end of file + +export default auth; diff --git a/src/test/auth.reducer.test.js b/src/test/auth.reducer.test.js new file mode 100644 index 0000000..e43a032 --- /dev/null +++ b/src/test/auth.reducer.test.js @@ -0,0 +1,195 @@ +import deepFreeze from 'deep-freeze'; +import { + setToken, + setProfile, + signedOut, + signIn, + signOut, +} from '../shared/auth/auth.actions'; +import { profile, auth } from '../shared/auth/auth.reducer'; +import {} from '../shared/auth/auth.types'; +import { + requestSaveMember, + requestSaveProfile, +} from '../settings/settings.actions'; + +describe('Profile reducer', () => { + it('should not change profile state on default', () => { + const state = {}; + const action = { type: "NULL::DEFAULT", }; + + deepFreeze(state); + deepFreeze(action); + + expect(profile(state, action)).toEqual(state); + }); + + it('should update profile on REQUEST_SAVE_PROFILE', () => { + const stateBefore = { + first_name: 'abcdef', + }; + const action = requestSaveProfile({ first_name: 'fib', last_name: 'nacci', }); + const stateAfter = { + first_name: 'fib', + last_name: 'nacci', + }; + + deepFreeze(stateBefore); + deepFreeze(action); + expect(profile(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update profile on REQUEST_SAVE_MEMBER', () => { + const stateBefore = { + username: 'Pierre', + }; + const action = requestSaveMember({ username: 'Cardin', }); + const stateAfter = { + username: 'Cardin', + }; + + deepFreeze(stateBefore); + deepFreeze(action); + expect(profile(stateBefore, action)).toEqual(stateAfter); + }); +}); + +describe('Auth reducer', () => { + it('should not change profile state on default', () => { + const state = {}; + const action = {type: "NULL::DEFAULT",}; + + deepFreeze(state); + deepFreeze(action); + + expect(auth(state, action)).toEqual(state); + }); + + it('should store token', () => { + const token = 'qwertyuiop12345'; + const stateBefore = { + dummy: [], + }; + const action = setToken(token); + const stateAfter = { + dummy: [], + token, + }; + + deepFreeze(token); + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update token', () => { + const token = 'qwertyuiop12345'; + const stateBefore = { + profile: {}, + token: 'asdfghjkl', + }; + const action = setToken(token); + const stateAfter = { + profile: {}, + token, + }; + + deepFreeze(token); + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); + + it('should clean state on SIGNED_OUT', () => { + const stateBefore = { + token: 'asdfghjkl', + profile: {}, + }; + const action = signedOut(); + const stateAfter = {}; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); + + it('should set profile', () => { + const stateBefore = { + token: 'asdfghjkl', + }; + const action = setProfile({ + id: 5, + username: 'Username1', + }); + const stateAfter = { + token: 'asdfghjkl', + profile: { + id: 5, + username: 'Username1', + } + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update profile', () => { + const stateBefore = { + token: 'asdfghjkl', + profile: { + username: 'Username1', + first_name: '1st name', + last_name: 'last name', + }, + }; + const action = requestSaveProfile({ + first_name: 'Jacob', + last_name: 'Adler', + }); + const stateAfter = { + token: 'asdfghjkl', + profile: { + username: 'Username1', + first_name: 'Jacob', + last_name: 'Adler', + } + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update member profile', () => { + const stateBefore = { + token: 'asdfghjkl', + profile: { + username: 'Username1', + first_name: '1st name', + last_name: 'last name', + }, + }; + const action = requestSaveProfile({ + username: 'JAdler', + }); + const stateAfter = { + token: 'asdfghjkl', + profile: { + username: 'JAdler', + first_name: '1st name', + last_name: 'last name', + } + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(auth(stateBefore, action)).toEqual(stateAfter); + }); +}); diff --git a/src/test/users.reducer.test.js b/src/test/users.reducer.test.js index bc300f8..c33d263 100644 --- a/src/test/users.reducer.test.js +++ b/src/test/users.reducer.test.js @@ -1,16 +1,119 @@ -import { expect } from 'chai'; import * as actions from '../users/user.actions'; import deepFreeze from 'deep-freeze'; -import users from '../users/users.reducer'; +import { user, users, getSortedUsers, clean } from '../users/users.reducer'; import usersMock from '../assets/mocks/users.json'; -describe('Users reducer', function() { + +describe('Clean reducer', () => { + it('should clean playing, team, position', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const action = actions.choosePlayersForMatch(); + const stateAfter = [ + {id: 1, playing: false, team: undefined, position: undefined, exp: 1000, }, + {id: 2, playing: false, team: undefined, position: undefined, exp: 1000, }, + {id: 3, playing: false, team: undefined, position: undefined, exp: 1000, }, + {id: 4, playing: false, team: undefined, position: undefined, exp: 1000, }, + {id: 5, playing: false, team: undefined, position: undefined, exp: 1000, }, + ]; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(clean(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not change state on default action', () => { + const state = [{ id: 0, username: 'U0', team: 'red', position: 'att', }]; + const action = { type: 'NULL::DEFAULT', }; + deepFreeze(state); + deepFreeze(action); + expect(users(state, action)).toEqual(state); + }); +}); + + +describe('User reducer', () => { + it('should create new user object', () => { + const stateBefore = {}; + const action = actions.userNew('Corn'); + const stateAfter = { + id: 0, + username: 'Corn', + exp: 1000, + }; + + deepFreeze(stateBefore); + deepFreeze(action); + expect(user(stateBefore, action)).toEqual(stateAfter); + }); + + it('should swap position', () => { + const stateBefore = { id: 0, username: 'U0', playing: true, team: 'red', position: 'att', }; + const stateAfter = { id: 0, username: 'U0', playing: true, team: 'red', position: 'def', }; + const action = actions.swapPositions(); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(user(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not swap position of non-player', () => { + const stateBefore = { id: 0, username: 'U0', team: 'red', position: 'att', }; + const stateAfter = { id: 0, username: 'U0', team: 'red', position: 'att', }; + const action = actions.swapPositions(); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(user(stateBefore, action)).toEqual(stateAfter); + }); + + it('should swap side', () => { + const stateBefore = { id: 0, username: 'U0', playing: true, team: 'red', position: 'att', }; + const stateAfter = { id: 0, username: 'U0', playing: true, team: 'blue', position: 'att', }; + const action = actions.swapSides(); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(user(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not swap side of non-player', () => { + const stateBefore = { id: 0, username: 'U0', team: 'red', position: 'att', }; + const stateAfter = { id: 0, username: 'U0', team: 'red', position: 'att', }; + const action = actions.swapSides(); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(user(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not change state on default action', () => { + const state = { id: 0, username: 'U0', team: 'red', position: 'att', }; + const action = { type: 'NULL::DEFAULT', }; + deepFreeze(state); + deepFreeze(action); + expect(users(state, action)).toEqual(state); + }); +}); + + +describe('User list reducer', function() { const username = 'Shannon'; it('should add user', function() { const stateBefore = []; const action = actions.userNew(username); const stateAfter = [{ - id: 0, + id: 1, username, exp: 1000, }]; @@ -18,42 +121,73 @@ describe('Users reducer', function() { deepFreeze(stateBefore); deepFreeze(action); - expect(users(stateBefore, action)).to.deep.equal(stateAfter); + expect(users(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update user', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const stateAfter = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1521, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const action = actions.userUpdate(3, { exp: 1521, }); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(users(stateBefore, action)).toEqual(stateAfter); }); it('should delete user', function() { - const stateBefore = [{ - id: 0, - username, - exp: 1000, - }]; - const action = actions.userDelete(0); - const stateAfter = []; + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const stateAfter = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const action = actions.userDelete(3); deepFreeze(stateBefore); deepFreeze(action); - expect(users(stateBefore, action)).to.deep.equal(stateAfter); + expect(users(stateBefore, action)).toEqual(stateAfter); }); it('should select user', function() { - const stateBefore = [{ - id: 0, - username, - exp: 1000, - }]; - const action = actions.userToggle(stateBefore[0]); - const stateAfter = [{ - id: 0, - username, - exp: 1000, - selected: true, - }]; + const stateBefore = [ + { id: 0, username: 'User0', exp: 1000, }, + { id: 1, username: 'User1', exp: 1000, }, + { id: 2, username: 'User2', exp: 1000, }, + ]; + const user = stateBefore[1]; + const action = actions.userToggle(user); + const stateAfter = [ + { id: 0, username: 'User0', exp: 1000, }, + { id: 1, username: 'User1', exp: 1000, selected: true, }, + { id: 2, username: 'User2', exp: 1000, }, + ]; deepFreeze(stateBefore); + deepFreeze(user); deepFreeze(action); - expect(users(stateBefore, action)).to.deep.equal(stateAfter); + expect(users(stateBefore, action)).toEqual(stateAfter); }); it('should choose four users with teams and positions', function() { @@ -62,12 +196,28 @@ describe('Users reducer', function() { deepFreeze(stateBefore); deepFreeze(action); const stateAfter = users(stateBefore, action).filter(u => u.playing); - expect(stateAfter).to.have.lengthOf(4); - for (let user of stateAfter) { - expect(user).to.contain.all.keys(['team', 'position']); + expect(stateAfter).toHaveLength(4); + for (const user of stateAfter) { + const keys = Object.keys(user); + expect(keys).toContain('team'); + expect(keys).toContain('position'); } }); + it('should throw error when insufficient no of players selected', () => { + const errorMsg = 'Insufficient number of players selected.'; + const stateBefore = [ + { id: 0, username: 'User0', exp: 1000, selected: true, }, + { id: 1, username: 'User1', exp: 1000, selected: true, }, + { id: 2, username: 'User2', exp: 1000, selected: true, }, + { id: 3, username: 'User3', exp: 1000, }, + ]; + const action = actions.choosePlayersForMatch(); + deepFreeze(stateBefore); + deepFreeze(action); + expect(() => users(stateBefore, action)).toThrowError(errorMsg); + }); + it('should not mutate state while sorting', function() { const stateBefore = usersMock; const actionExp = actions.sortByExp(); @@ -75,7 +225,169 @@ describe('Users reducer', function() { deepFreeze(stateBefore); deepFreeze(actionExp); deepFreeze(actionName); - expect(users.bind(null, stateBefore, actionExp)).to.not.throw(Error); - expect(users.bind(null, stateBefore, actionName)).to.not.throw(Error); + expect(users.bind(null, stateBefore, actionExp)).not.toThrowError(); + expect(users.bind(null, stateBefore, actionName)).not.toThrowError(); + }); + + it('should sort by exp ascending', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + {id: 5, exp: 1004, }, + ]; + const action = actions.sortBy('exp', true); + const stateAfter = [ + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 5, exp: 1004, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + ]; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(users(stateBefore, action)).toEqual(stateAfter); + }); + + it('should swap sides', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att'}, + {id: 2, playing: true, team: 'red', position: 'def'}, + {id: 3, playing: true, team: 'blue', position: 'att'}, + {id: 4, playing: true, team: 'blue', position: 'def'}, + ]; + const action = actions.swapSides(); + const stateAfter = [ + {id: 1, playing: true, team: 'blue', position: 'att'}, + {id: 2, playing: true, team: 'blue', position: 'def'}, + {id: 3, playing: true, team: 'red', position: 'att'}, + {id: 4, playing: true, team: 'red', position: 'def'}, + ]; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(users(stateBefore, action)).toEqual(stateAfter); + }); + + it('should swap positions', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att'}, + {id: 2, playing: true, team: 'red', position: 'def'}, + {id: 3, playing: true, team: 'blue', position: 'att'}, + {id: 4, playing: true, team: 'blue', position: 'def'}, + ]; + const action = actions.swapPositions(); + const stateAfter = [ + {id: 1, playing: true, team: 'red', position: 'def'}, + {id: 2, playing: true, team: 'red', position: 'att'}, + {id: 3, playing: true, team: 'blue', position: 'def'}, + {id: 4, playing: true, team: 'blue', position: 'att'}, + ]; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(users(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update users list', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const action = actions.updateUsers([ + {id: 3, exp: 1050, }, + {id: 4, exp: 1050, }, + {id: 5, exp: 1050, }, + ]); + const stateAfter = getSortedUsers([ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1050, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1050, }, + {id: 5, exp: 1050, }, + ], 'exp', false); + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(users(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not change state on default action', () => { + const state = { + users: [], + }; + const action = { type: 'NULL::DEFAULT', }; + deepFreeze(state); + deepFreeze(action); + expect(users(state, action)).toEqual(state); + }); + + it('should set users as empty array on wrong response', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 1000, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1000, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 1000, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1000, }, + {id: 5, exp: 1000, }, + ]; + const action = actions.receiveUsers({}); + const stateAfter = []; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(() => users(stateBefore, action)).not.toThrowError(); + expect(users(stateBefore, action)).toEqual(stateAfter); + }); +}); + +describe('Sorting users by column', () => { + it('should sort by exp ascending', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + {id: 5, exp: 1004, }, + ]; + const stateAfter = [ + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 5, exp: 1004, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + ]; + + deepFreeze(stateBefore); + expect(getSortedUsers(stateBefore, 'exp', true)).toEqual(stateAfter); + }); + + it('should sort by exp descending', () => { + const stateBefore = [ + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + {id: 5, exp: 1004, }, + ]; + const stateAfter = [ + {id: 4, playing: true, team: 'blue', position: 'def', exp: 1121, }, + {id: 2, playing: true, team: 'red', position: 'def', exp: 1015, }, + {id: 5, exp: 1004, }, + {id: 1, playing: true, team: 'red', position: 'att', exp: 978, }, + {id: 3, playing: true, team: 'blue', position: 'att', exp: 866, }, + ]; + + deepFreeze(stateBefore); + expect(getSortedUsers(stateBefore, 'exp', false)).toEqual(stateAfter); }); -}); \ No newline at end of file +}); diff --git a/src/users/components/MatchToolbar.js b/src/users/components/MatchToolbar.js index 264b952..4e7a21d 100644 --- a/src/users/components/MatchToolbar.js +++ b/src/users/components/MatchToolbar.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import { connect } from 'react-redux'; import * as UserActions from '../user.actions'; import { raiseError } from '../../shared/notifier.actions'; @@ -11,22 +11,35 @@ const mapDispatchToProps = (dispatch) => ({ catch(err) { dispatch(raiseError(err.message)); } window.scrollTo(0, 0); }, - sortByExp: () => dispatch(UserActions.sortBy("exp", false)), - sortByName: () => dispatch(UserActions.sortBy("username")), + sortByExp: (direction) => dispatch(UserActions.sortBy("exp", direction)), + sortByName: (direction) => dispatch(UserActions.sortBy("username", direction)), }); @connect(mapStateToProps, mapDispatchToProps) -class MatchToolbar extends Component { +class MatchToolbar extends React.Component { + constructor(props) { + super(props); + this.state = { + sortDir: false, + }; + } + + sort = (method) => { + const { sortDir } = this.state; + method(sortDir); + this.setState({sortDir: !sortDir}); + }; + render() { const { sortByName, sortByExp, handlePlay } = this.props; return ( - + - + diff --git a/src/users/users.reducer.js b/src/users/users.reducer.js index d98ace6..64a67e1 100644 --- a/src/users/users.reducer.js +++ b/src/users/users.reducer.js @@ -2,7 +2,7 @@ import * as types from "./user.types"; import choice from "../utils/choice"; import getRoles from "../utils/roles"; -const user = (state, action) => { +export const user = (state = {}, action) => { switch (action.type) { case types.ADD: return { @@ -13,15 +13,15 @@ const user = (state, action) => { if (state.id !== action.id) return state; return Object.assign({}, state, action.userData); case types.UPDATE_LIST: - return Object.assign(state, action.userList.find(u => u.id === state.id)); + return Object.assign({}, state, action.userList.find(u => u.id === state.id)); case types.SWAP_POSITIONS: if (state.playing) { - return Object.assign(state, { position: state.position === 'att' ? 'def' : 'att'}); + return Object.assign({}, state, { position: state.position === 'att' ? 'def' : 'att'}); } return state; case types.SWAP_SIDES: if (state.playing) { - return Object.assign(state, { team: state.team === 'red' ? 'blue' : 'red' }) + return Object.assign({}, state, { team: state.team === 'red' ? 'blue' : 'red' }) } return state; default: @@ -35,7 +35,7 @@ export const getSortedUsers = (state, column, isAscendingOrder) => return isAscendingOrder ? comparison : -comparison; }); -const clean = (state = [], action) => { +export const clean = (state = [], action) => { switch (action.type) { case types.CHOOSE: return state.map(u => ({ @@ -49,7 +49,7 @@ const clean = (state = [], action) => { } }; -export default (state = [], action) => { +export const users = (state = [], action) => { switch (action.type) { case types.ADD: return [ @@ -83,3 +83,5 @@ export default (state = [], action) => { return state; } }; + +export default users; From 1e7dbbebc67d724aaf9a2f5d9e8ea8081c8d5530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sat, 4 Mar 2017 18:22:37 +0100 Subject: [PATCH 10/60] Unit tests for users sagas --- src/test/users.sagas.test.js | 122 ++++++++++++++++++++++++++++------- src/users/users.sagas.js | 13 ++-- 2 files changed, 105 insertions(+), 30 deletions(-) diff --git a/src/test/users.sagas.test.js b/src/test/users.sagas.test.js index 1ec69f3..e2d4e4a 100644 --- a/src/test/users.sagas.test.js +++ b/src/test/users.sagas.test.js @@ -3,41 +3,115 @@ import api from '../api'; import response from '../assets/mocks/users.json'; import * as UserActions from '../users/user.actions'; import { raiseError } from '../shared/notifier.actions'; -import { fetchUsers } from '../users/users.sagas.js'; +import { fetchUsers, fetchUpdateUsers } from '../users/users.sagas.js'; +import { getCurrentTeam } from '../shared/teams/teams.sagas'; -describe('Fetch user list - success scenario', () => { +describe('FetchUsers saga ', () => { const iterator = fetchUsers(); - const currentTeamId = 1; + const currentTeam = {id: 1}; + const url = api.urls.teamMemberList(currentTeam.id); - it('should select team id', () => { - expect(JSON.stringify(iterator.next().value)).toEqual(JSON.stringify(select(() => currentTeamId))); - }); + describe('Scenario 1 - success scenario', () => { + it('should select team id', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call(getCurrentTeam)); + }); + + it('should call fetch api', () => { + expect(iterator.next(currentTeam).value).toEqual(call(api.requests.get, url)); + }); + + it('should put response action', () => { + const iter = iterator.next(response).value; + expect(iter).toEqual(put(UserActions.receiveUsers(response))); + }); - it('should call fetch api', () => { - const url = api.urls.teamMemberList(currentTeamId); - expect(iterator.next(currentTeamId).value).toEqual(call(api.requests.get, url)); + it('should return from saga', () => { + const iter = iterator.next().done; + expect(iter).toBe(true); + }); }); - it('should put response action', () => { - expect(iterator.next(response).value).toEqual(put(UserActions.receiveUsers(response))); + + describe('Scenario 2: - failure scenario', () => { + const iterator = fetchUsers(); + const errorMsg = 'Unable to fetch user list'; + + it('should select team id', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call(getCurrentTeam)); + }); + + it('should call fetch api', () => { + const iter = iterator.next(currentTeam).value; + expect(iter).toEqual(call(api.requests.get, url)); + }); + + it('should put raise error action', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should return from saga', () => { + const iter = iterator.next().done; + expect(iter).toBe(true); + }); }); + }); -describe('Fetch user list - failure scenario', () => { - const iterator = fetchUsers(); - const errorMsg = 'Unable to fetch user list'; - const currentTeamId = 1; - it('should select team id', () => { - expect(JSON.stringify(iterator.next().value)).toEqual(JSON.stringify(select(() => currentTeamId))); - }); +describe('FetchUpdateUsers saga', () => { + const currentTeam = { id: 6 }; + const url = api.urls.teamMemberList(currentTeam.id); + const response = [{id: 6}, {id: 7}]; + const errorMsg = 'Failed to fetch users list'; + + describe('Scenario 1: Success', () => { + const iterator = fetchUpdateUsers(); + + it('should call to get current team', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call(getCurrentTeam)) + }); + + it('should call API to get team users list', () => { + const iter = iterator.next(currentTeam).value; + expect(iter).toEqual(call(api.requests.get, url, {}, errorMsg)); + }); + + it('should put updateUsers action', () => { + const iter = iterator.next(response).value; + expect(iter).toEqual(put(UserActions.updateUsers(response))); + }); - it('should call fetch api', () => { - const url = api.urls.teamMemberList(currentTeamId); - expect(iterator.next(currentTeamId).value).toEqual(call(api.requests.get, url)); + it('should return from saga', () => { + const iter = iterator.next(response).done; + expect(iter).toBe(true); + }); }); - it('should put raise error action', () => { - expect(iterator.throw(errorMsg).value).toEqual(put(raiseError(errorMsg))); + describe('Scenario 2: Failure', () => { + const iterator = fetchUpdateUsers(); + + it('should call to get current team', () => { + const iter = iterator.next().value; + expect(iter).toEqual(call(getCurrentTeam)) + }); + + it('should call API to get team users list', () => { + const iter = iterator.next(currentTeam).value; + expect(iter).toEqual(call(api.requests.get, url, {}, errorMsg)); + }); + + it('should put updateUsers action', () => { + const iter = iterator.throw(errorMsg).value; + expect(iter).toEqual(put(raiseError(errorMsg))); + }); + + it('should return from saga', () => { + const iter = iterator.next(response).done; + expect(iter).toBe(true); + }); }); -}); \ No newline at end of file +}); diff --git a/src/users/users.sagas.js b/src/users/users.sagas.js index a8b9e87..7b00bec 100644 --- a/src/users/users.sagas.js +++ b/src/users/users.sagas.js @@ -2,10 +2,11 @@ import { call, put, select } from 'redux-saga/effects'; import api from '../api'; import * as UserActions from './user.actions'; import { raiseError } from '../shared/notifier.actions'; +import { getCurrentTeam } from '../shared/teams/teams.sagas'; export function* fetchUsers() { - const currentTeamId = yield select(state => state.teams.selected); - const url = api.urls.teamMemberList(currentTeamId); + const currentTeam = yield call(getCurrentTeam); + const url = api.urls.teamMemberList(currentTeam.id); try { const response = yield call(api.requests.get, url); yield put(UserActions.receiveUsers(response)); @@ -15,12 +16,12 @@ export function* fetchUsers() { } export function* fetchUpdateUsers() { - const currentTeamId = yield select(state => state.teams.selected); - const url = api.urls.teamMemberList(currentTeamId); + const currentTeam = yield call(getCurrentTeam); + const url = api.urls.teamMemberList(currentTeam.id); try { - const response = yield call(api.requests.get, url); + const response = yield call(api.requests.get, url, {}, 'Failed to fetch users list'); yield put(UserActions.updateUsers(response)); } catch (error) { yield put(raiseError(error)); } -}; +} From 9d21627ef7a5445cfa5012f1517c7480434480e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sun, 5 Mar 2017 15:08:58 +0100 Subject: [PATCH 11/60] Add travis config --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b16d7f9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - "6" \ No newline at end of file From 89b15d7e494d807439f2703a704deb0c7f20991c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Mon, 6 Mar 2017 10:26:39 +0100 Subject: [PATCH 12/60] Fix paths in package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9647c42..9739800 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "src/**/*.{js,jsx}" ], "setupFiles": [ - "\\config\\polyfills.js" + "/config/polyfills.js" ], "testPathIgnorePatterns": [ "[/\\\\](build|docs|node_modules)[/\\\\]" @@ -95,8 +95,8 @@ "testURL": "http://localhost", "transform": { "^.+\\.(js|jsx)$": "/node_modules/babel-jest", - "^.+\\.css$": "\\config\\jest\\cssTransform.js", - "^(?!.*\\.(js|jsx|css|json)$)": "\\config\\jest\\fileTransform.js" + "^.+\\.css$": "/config/jest/cssTransform.js", + "^(?!.*\\.(js|jsx|css|json)$)": "/config/jest/fileTransform.js" }, "transformIgnorePatterns": [ "[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$" From 8edaf14fd2d4179cfc0b29c5b2480ea6856e1a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Sun, 5 Mar 2017 15:17:22 +0100 Subject: [PATCH 13/60] Configure travis run script --- .travis.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b16d7f9..d4511c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ language: node_js node_js: - - "6" \ No newline at end of file + - "6" +script: + - npm test -- --coverage + - npm build From 1570d2db275a58bbe921b42d5d35a8ef14012db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Fri, 10 Mar 2017 20:58:51 +0100 Subject: [PATCH 14/60] UTs for teams reducer --- src/shared/teams/teams.reducer.js | 14 +-- src/test/teams.reducer.test.js | 171 ++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/test/teams.reducer.test.js diff --git a/src/shared/teams/teams.reducer.js b/src/shared/teams/teams.reducer.js index b7f6778..cc0888a 100644 --- a/src/shared/teams/teams.reducer.js +++ b/src/shared/teams/teams.reducer.js @@ -3,7 +3,7 @@ import { UPDATE_PROFILE } from '../../profile/profile.types'; import { SIGNED_OUT } from '../auth/auth.types'; export const getSelectedTeam = (state) => state.joined.find(team => team.id === state.selected); -export default (state = { joined: [], selected: 0, pending: [] }, action) => { +export const teams = (state = { joined: [], selected: 0, pending: [] }, action) => { switch (action.type) { case TEAM_CREATED: return { @@ -13,8 +13,8 @@ export default (state = { joined: [], selected: 0, pending: [] }, action) => { case SET_TEAMS: return { ...state, - joined: action.teams, - pending: action.pending, + joined: action.teams || [], + pending: action.pending || [], }; case SELECT_TEAM: return { @@ -29,11 +29,11 @@ export default (state = { joined: [], selected: 0, pending: [] }, action) => { return { ...state, joined: state.joined.map( - t => t.id !== state.selected ? t : Object.assign(t, {username: action.response.username}) + t => t.id !== state.selected ? t : Object.assign({}, t, {username: action.response.username}) ), }; case SIGNED_OUT: - return { joined: [], selected: 0 }; + return { joined: [], selected: 0, pending: [] }; case PENDING_MEMBERS: return { ...state, @@ -42,4 +42,6 @@ export default (state = { joined: [], selected: 0, pending: [] }, action) => { default: return state; } -} +}; + +export default teams; diff --git a/src/test/teams.reducer.test.js b/src/test/teams.reducer.test.js new file mode 100644 index 0000000..5da1837 --- /dev/null +++ b/src/test/teams.reducer.test.js @@ -0,0 +1,171 @@ +import * as actions from '../shared/teams/teams.actions'; +import { signedOut } from '../shared/auth/auth.actions'; +import { profileUpdate } from '../profile/profile.actions'; +import { teams, getSelectedTeam } from '../shared/teams/teams.reducer'; +import deepFreeze from 'deep-freeze'; + +describe('Teams teams', () => { + it('should add created team to joined list', () => { + const team = { id: 1, name: 'Team1', }; + const stateBefore = { + selected: 0, + pending: [], + joined: [], + }; + const action = actions.teamCreated(team); + const stateAfter = { + selected: 0, + pending: [], + joined: [ team ], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should select team', () => { + const team = { id: 1, name: 'Team1', }; + const stateBefore = { + selected: 0, + pending: [], + joined: [], + }; + const action = actions.selectTeam(team); + const stateAfter = { + selected: 1, + pending: [], + joined: [], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should set teams', () => { + const teamList = [{ id: 1, name: 'Team1', }, { id: 2, name: 'Team2', }]; + const stateBefore = { + selected: 0, + pending: [], + joined: [], + }; + const action = actions.setTeams({teams: teamList,}); + const stateAfter = { + selected: 0, + pending: [], + joined: teamList, + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should clean on signed out', () => { + const teamList = [{ id: 1, name: 'Team1', }, { id: 2, name: 'Team2', }]; + const stateBefore = { + selected: 5, + pending: [teamList], + joined: [teamList], + }; + const action = signedOut(); + const stateAfter = { + selected: 0, + pending: [], + joined: [], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should update pending list', () => { + const teamList = [{ id: 1, name: 'Team1', }, { id: 2, name: 'Team2', }]; + const stateBefore = { + selected: 0, + pending: [], + joined: [], + }; + const action = actions.setPendingMembers(teamList); + const stateAfter = { + selected: 0, + pending: teamList, + joined: [], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not change state on default', () => { + const teamList = [{ id: 1, name: 'Team1', }, { id: 2, name: 'Team2', }]; + const stateBefore = { + selected: 0, + pending: teamList, + joined: teamList, + }; + const action = { type: 'NULL::DEFAULT '}; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateBefore); + }); + + it('should update profile', () => { + const stateBefore = { + selected: 1, + pending: [], + joined: [{ id: 1, name: 'Team1', username: 'Wacko'},], + }; + const action = profileUpdate({ username: 'UName'}); + const stateAfter = { + selected: 1, + pending: [], + joined: [{ id: 1, name: 'Team1', username: 'UName'},], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); + + it('should not update profile', () => { + const stateBefore = { + selected: 1, + pending: [], + joined: [{ id: 1, name: 'Team1', username: 'Wacko'},], + }; + const action = profileUpdate({ prop: 'unk'}); + const stateAfter = { + selected: 1, + pending: [], + joined: [{ id: 1, name: 'Team1', username: 'Wacko'},], + }; + + deepFreeze(stateBefore); + deepFreeze(action); + + expect(teams(stateBefore, action)).toEqual(stateAfter); + }); +}); + +describe('getSelectedTeam', () => { + it('should get selected team', () => { + const state = { + selected: 2, + pending: [], + joined: [{ id: 1, name: 'Team1', }, { id: 2, name: 'Team2', }], + }; + expect(getSelectedTeam(state)).toEqual({ id: 2, name: 'Team2', }); + }); +}); \ No newline at end of file From 78f7a03839d052fe1de29eaadcd88b050b157900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Fri, 10 Mar 2017 21:22:48 +0100 Subject: [PATCH 15/60] Make play result score input field numeric --- src/play/components/FoosballTable.js | 6 ++++-- src/play/components/PlayResult.js | 16 ++++------------ src/test/play.components.test.js | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 src/test/play.components.test.js diff --git a/src/play/components/FoosballTable.js b/src/play/components/FoosballTable.js index 3fe5d61..18f0f9e 100644 --- a/src/play/components/FoosballTable.js +++ b/src/play/components/FoosballTable.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import { Row, Col, ButtonGroup, Image, Well } from 'react-bootstrap'; import { connect } from 'react-redux'; +import { publish } from '../../matches/match.actions'; import UserPicker from '../../users/components/UserPicker'; import PlayResult from './PlayResult'; import PlayStats from './PlayStats'; @@ -17,12 +18,13 @@ const mapDispatchToProps = (dispatch) => ({ try { dispatch(choosePlayersForMatch()) } catch(err) { dispatch(raiseError(err.message)); } }, + publishMatch: (data, callback) => dispatch(publish(data, callback)), }); @connect(mapStateToProps, mapDispatchToProps) class FoosballTable extends Component { render() { - const { users, swapSides, swapPositions, regenerate } = this.props; + const { users, swapSides, swapPositions, regenerate, publishMatch } = this.props; const playing = users.filter(u => u.playing); return ( @@ -47,7 +49,7 @@ class FoosballTable extends Component { - + { playing.length === 4 ? : null } ); diff --git a/src/play/components/PlayResult.js b/src/play/components/PlayResult.js index c34e1d0..858687d 100644 --- a/src/play/components/PlayResult.js +++ b/src/play/components/PlayResult.js @@ -1,14 +1,7 @@ import React from 'react'; -import { connect } from 'react-redux'; -import * as MatchActions from '../../matches/match.actions'; import { Button, Row, Col, FormControl } from 'react-bootstrap'; -const mapStateToProps = ({users}) => ({users}); -const mapDispatchToProps = (dispatch) => ({ - publish: (data, callback) => dispatch(MatchActions.publish(data, callback)), -}); -@connect(mapStateToProps, mapDispatchToProps) class PlayResult extends React.Component { constructor(props) { super(props); @@ -21,14 +14,13 @@ class PlayResult extends React.Component { onInputChange = (team) => (event) => this.setState({ [team]: event.target.value }); handleFinish = () => { - const { users, publish } = this.props; - const players = users.filter(u => u.playing); + const { players, onPublish } = this.props; const requestData = { ...(players.reduce((o, p) => Object.assign(o, {[`${p.team}_${p.position}`]: p.username}), {})), red_score: this.state.red, blue_score: this.state.blue, }; - publish(requestData, this.clear); + onPublish(requestData, this.clear); }; clear = () => this.setState({ blue: 0, red: 0, }); @@ -42,7 +34,7 @@ class PlayResult extends React.Component { { + it('should render two numeric inputs', () => { + const component = shallow(); + const inputs = component.find(FormControl); + expect(inputs).toHaveLength(2); + expect(inputs.first().prop('type')).toEqual('number'); + expect(inputs.last().prop('type')).toEqual('number'); + }); +}); From 2cf349d2a636c17ea12083741c9801a45cc189cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 00:06:17 +0100 Subject: [PATCH 16/60] Regenerate score on player role change --- src/play/play.sagas.js | 34 ++++++++++++--------- src/test/play.sagas.test.js | 48 +++++++++++++++++++----------- src/users/components/UserPicker.js | 16 ++++++---- src/users/user.actions.js | 11 +++++-- src/users/user.types.js | 1 + src/users/users.reducer.js | 5 ++-- 6 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/play/play.sagas.js b/src/play/play.sagas.js index 15a7911..1895580 100644 --- a/src/play/play.sagas.js +++ b/src/play/play.sagas.js @@ -1,7 +1,7 @@ -import { call, take, put, select } from 'redux-saga/effects'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; import api from '../api'; import { getCurrentTeam } from '../shared/teams/teams.sagas'; -import { CHOOSE } from '../users/user.types'; +import { CHOOSE, SWAP_SIDES, SWAP_POSITIONS, ASSIGN } from '../users/user.types'; import { raiseError } from '../shared/notifier.actions'; import { requestStatsDone } from './play.actions'; @@ -12,17 +12,23 @@ export const stateUsersPlayingSelector = ({users}) => users {} ); -export function* playScore() { - while (true) { - yield take(CHOOSE); - const players = yield select(stateUsersPlayingSelector); - const currentTeam = yield call(getCurrentTeam); - const url = api.urls.teamMatchPoints(currentTeam.id); - try { - const response = yield call(api.requests.get, url, players, 'Unable to get match score statistics.'); - yield put(requestStatsDone(response)); - } catch (error) { - yield put(raiseError(error)); - } + +export function* fetchPlayScore() { + const players = yield select(stateUsersPlayingSelector); + if (Object.keys(players).length !== 4) return; + const currentTeam = yield call(getCurrentTeam); + const url = api.urls.teamMatchPoints(currentTeam.id); + try { + const response = yield call(api.requests.get, url, players, 'Unable to get match score statistics.'); + yield put(requestStatsDone(response)); + } catch (error) { + yield put(raiseError(error)); } } + +export function* playScore() { + yield takeLatest(CHOOSE, fetchPlayScore); + yield takeLatest(SWAP_POSITIONS, fetchPlayScore); + yield takeLatest(SWAP_SIDES, fetchPlayScore); + yield takeLatest(ASSIGN, fetchPlayScore); +} diff --git a/src/test/play.sagas.test.js b/src/test/play.sagas.test.js index 150c490..2b3e4e2 100644 --- a/src/test/play.sagas.test.js +++ b/src/test/play.sagas.test.js @@ -1,8 +1,8 @@ -import { call, put, take, select } from 'redux-saga/effects'; +import { call, put, takeLatest, select } from 'redux-saga/effects'; import api from '../api'; import { raiseError, showInfo } from '../shared/notifier.actions'; -import { stateUsersPlayingSelector, playScore } from '../play/play.sagas'; -import { CHOOSE } from '../users/user.types'; +import { stateUsersPlayingSelector, fetchPlayScore, playScore } from '../play/play.sagas'; +import { CHOOSE, SWAP_SIDES, SWAP_POSITIONS, ASSIGN } from '../users/user.types'; import { getCurrentTeam } from '../shared/teams/teams.sagas'; import { requestStatsDone } from '../play/play.actions'; @@ -24,11 +24,7 @@ describe('PlayScore saga', () => { }; describe('Scenario 1: Successfully obtained play score', () => { - const iterator = playScore(); - it('should wait to take CHOOSE', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(CHOOSE)); - }); + const iterator = fetchPlayScore(); it('should select playing users', () => { const iter = iterator.next().value; @@ -50,17 +46,13 @@ describe('PlayScore saga', () => { expect(iter).toEqual(put(requestStatsDone(response))); }); - it('should not return from saga', () => { - expect(iterator.next().done).toBe(false); + it('should return from saga', () => { + expect(iterator.next().done).toBe(true); }); }); describe('Scenario 2: Failed to obtain play score from API', () => { - const iterator = playScore(); - it('should wait to take CHOOSE', () => { - const iter = iterator.next().value; - expect(iter).toEqual(take(CHOOSE)); - }); + const iterator = fetchPlayScore(); it('should select playing users', () => { const iter = iterator.next().value; @@ -82,8 +74,28 @@ describe('PlayScore saga', () => { expect(iter).toEqual(put(raiseError(errorMsg))); }); - it('should not return from saga', () => { - expect(iterator.next().done).toBe(false); + it('should return from saga', () => { + expect(iterator.next().done).toBe(true); + }); + }); + + describe('React to all player changing actions', () => { + const iterator = playScore(); + + it('should take latest CHOOSE', () => { + expect(iterator.next().value).toEqual(takeLatest(CHOOSE, fetchPlayScore)); + }); + + it('should take latest SWAP_POSITIONS', () => { + expect(iterator.next().value).toEqual(takeLatest(SWAP_POSITIONS, fetchPlayScore)); + }); + + it('should take latest SWAP_SIDES', () => { + expect(iterator.next().value).toEqual(takeLatest(SWAP_SIDES, fetchPlayScore)); + }); + + it('should take latest ASSIGN', () => { + expect(iterator.next().value).toEqual(takeLatest(ASSIGN, fetchPlayScore)); }); }); }); @@ -105,4 +117,4 @@ describe('Playing users selector', () => { blue_def: 5, }; expect(stateUsersPlayingSelector(state)).toEqual(expectedData); -}); \ No newline at end of file +}); diff --git a/src/users/components/UserPicker.js b/src/users/components/UserPicker.js index 74e8d5b..cf76eb1 100644 --- a/src/users/components/UserPicker.js +++ b/src/users/components/UserPicker.js @@ -1,7 +1,7 @@ -import React, { Component } from 'react'; +import React from 'react'; import { DropdownButton, MenuItem } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { userUpdate } from '../user.actions'; +import { userUpdate, userAssign } from '../user.actions'; const mapStateToProps = ({users}) => ({ users: users.filter(u => u.selected), @@ -11,12 +11,12 @@ const mapDispatchToProps = dispatch => ({ userUpdate(user.id, {playing: false, team: undefined, position: undefined}) ), assignUser: (user, team, position) => dispatch( - userUpdate(user.id, {playing: true, team, position}) + userAssign(user.id, {playing: true, team, position}) ), }); @connect(mapStateToProps, mapDispatchToProps) -class UserPicker extends Component { +class UserPicker extends React.Component { getUsersOptions = () => { const { users } = this.props; return users.length === 0 ? @@ -46,8 +46,12 @@ class UserPicker extends Component { const user = users.find(u => u.team === team && u.position === position); return ( - + + {position.toUpperCase()} {this.getUsersOptions()} ); diff --git a/src/users/user.actions.js b/src/users/user.actions.js index 77ee5eb..a17e8b4 100644 --- a/src/users/user.actions.js +++ b/src/users/user.actions.js @@ -29,8 +29,15 @@ export const userUpdate = (id, userData) => ({ userData, }); -export const choosePlayersForMatch = () => ({ - type: types.CHOOSE +export const userAssign = (id, userData) => ({ + type: types.ASSIGN, + id, + userData, +}); + +export const choosePlayersForMatch = (preset) => ({ + type: types.CHOOSE, + preset, }); export const sortByExp = () => ({ diff --git a/src/users/user.types.js b/src/users/user.types.js index d7f84d7..5c95c3c 100644 --- a/src/users/user.types.js +++ b/src/users/user.types.js @@ -7,3 +7,4 @@ export const SORT = 'USER::SORT'; export const UPDATE_LIST = 'USER::UPDATE::LIST'; export const SWAP_SIDES = 'USER::SWAP::SIDES'; export const SWAP_POSITIONS = 'USER::SWAP::POSITIONS'; +export const ASSIGN = 'USER::ASSIGN'; diff --git a/src/users/users.reducer.js b/src/users/users.reducer.js index 64a67e1..549cd01 100644 --- a/src/users/users.reducer.js +++ b/src/users/users.reducer.js @@ -10,6 +10,7 @@ export const user = (state = {}, action) => { }; case types.CHOOSE: case types.UPDATE: + case types.ASSIGN: if (state.id !== action.id) return state; return Object.assign({}, state, action.userData); case types.UPDATE_LIST: @@ -59,6 +60,7 @@ export const users = (state = [], action) => { case types.UPDATE: case types.SWAP_POSITIONS: case types.SWAP_SIDES: + case types.ASSIGN: return state.map(u => user(u, action)); case types.DELETE: return state.filter(user => user.id !== action.id); @@ -68,8 +70,7 @@ export const users = (state = [], action) => { if (selected.length < 4) { //TODO Feature request 1-1 matches throw new Error("Insufficient number of players selected."); } - const chosen = choice(selected, 4); - const playing = getRoles(chosen); + const playing = getRoles(choice(selected, 4)); return intermediateState.map(u => (playing[u.username]) ? playing[u.username] : u); case types.SORT: return getSortedUsers(state, action.column, action.isAscendingOrder); From 2e0631597dbcf5f52e7ee5dcb90130af961acede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 00:16:49 +0100 Subject: [PATCH 17/60] Don't reload the page while changing matches page Collapse navbar on select on mobile --- src/shared/components/Header.js | 2 +- src/shared/components/NaivePager.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 3042f16..8043106 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -43,7 +43,7 @@ export default class Header extends React.Component { const username = profile && profile.hasOwnProperty('username') ? profile.username : ''; return (
- + TFoosball diff --git a/src/shared/components/NaivePager.js b/src/shared/components/NaivePager.js index 8ab5375..efade0e 100644 --- a/src/shared/components/NaivePager.js +++ b/src/shared/components/NaivePager.js @@ -1,17 +1,19 @@ import React from 'react' import {Pager} from 'react-bootstrap'; +import {browserHistory} from 'react-router'; const NaivePager = ({page, totalPages, prefix}) => { + const onClick = (url) => () => browserHistory.push(url); return ( { page > 1 ? - « Previous Page : + « Previous Page : null } { page < totalPages ? - Next Page » : + Next Page » : null } From 51edaf9614e1770e799e2f83610169c2b10cb049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 00:21:02 +0100 Subject: [PATCH 18/60] Swap colors at table --- src/play/components/FoosballTable.js | 12 ++++++------ src/play/components/PlayResult.js | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/play/components/FoosballTable.js b/src/play/components/FoosballTable.js index 18f0f9e..11e2af6 100644 --- a/src/play/components/FoosballTable.js +++ b/src/play/components/FoosballTable.js @@ -33,19 +33,19 @@ class FoosballTable extends Component { - + - - + + - + - - + + diff --git a/src/play/components/PlayResult.js b/src/play/components/PlayResult.js index 858687d..d319659 100644 --- a/src/play/components/PlayResult.js +++ b/src/play/components/PlayResult.js @@ -33,11 +33,11 @@ class PlayResult extends React.Component { @@ -45,11 +45,11 @@ class PlayResult extends React.Component { From c07648820b611dcdc8a9fb3b9589b6fd275a80eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 21:16:51 +0100 Subject: [PATCH 19/60] Add total matches info to MatchList --- src/matches/components/MatchList.js | 83 +++++++++++++----------- src/matches/components/MatchesLayout.js | 2 +- src/matches/matches.reducer.js | 5 +- src/profile/components/ProfileMatches.js | 12 +++- src/profile/profile.reducer.js | 4 +- src/ranking/ranking.reducer.js | 1 - 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/src/matches/components/MatchList.js b/src/matches/components/MatchList.js index 73402ec..43fb2c9 100644 --- a/src/matches/components/MatchList.js +++ b/src/matches/components/MatchList.js @@ -4,47 +4,52 @@ import MatchItem from './MatchItem'; export default class MatchList extends React.Component { render() { - const { matches, withOptions, onRemove, username } = this.props; + const { matches, withOptions, onRemove, username, count } = this.props; return ( - - - - - Red team - - - Score - - - Blue team - - - EXP - - { - withOptions ? - - Options - : - null - } - - - - { - matches.map((match, idx) => - - ) - } - -
+
+ + + + + Red team + + + Score + + + Blue team + + + EXP + + { + withOptions ? + + Options + : + null + } + + + + { + matches.map((match, idx) => + + ) + } + +
+

+ Total matches: { count } +

+
); }; } diff --git a/src/matches/components/MatchesLayout.js b/src/matches/components/MatchesLayout.js index eb3a440..8179649 100644 --- a/src/matches/components/MatchesLayout.js +++ b/src/matches/components/MatchesLayout.js @@ -23,7 +23,7 @@ export default class MatchesLayout extends React.Component { - + diff --git a/src/matches/matches.reducer.js b/src/matches/matches.reducer.js index 9af6144..2b970a2 100644 --- a/src/matches/matches.reducer.js +++ b/src/matches/matches.reducer.js @@ -1,6 +1,8 @@ import * as types from './match.types'; -export default (state = { page: 1, totalPages: 1, list: [] }, action) => { +export const defaultData = {list: [], page: 1, totalPages: 1, count: 0 }; + +export default (state = defaultData, action) => { switch (action.type) { case types.LIST: return { @@ -8,6 +10,7 @@ export default (state = { page: 1, totalPages: 1, list: [] }, action) => { list: action.response.results, page: parseInt(action.response.page, 10), totalPages: Math.ceil(action.response.count / action.response.page_size), + count: action.response.count, }; default: return state; diff --git a/src/profile/components/ProfileMatches.js b/src/profile/components/ProfileMatches.js index d1549d8..edf51e0 100644 --- a/src/profile/components/ProfileMatches.js +++ b/src/profile/components/ProfileMatches.js @@ -6,6 +6,7 @@ import * as ModalActions from '../../shared/modal.actions'; import * as MatchActions from '../../matches/match.actions'; import NaivePager from '../../shared/components/NaivePager'; import Loading from '../../shared/components/Loading'; +import { defaultData } from '../../matches/matches.reducer'; const mapStateToProps = ({ profile: { matches } }) => ({ matches }); const mapDispatchToProps = (dispatch) => ({ @@ -13,7 +14,7 @@ const mapDispatchToProps = (dispatch) => ({ remove: (id) => dispatch(MatchActions.remove(id)), }); -const ProfileMatches = ({ matches = {list: [], page: 1, totalPages:1}, onRemove, remove, params: {username} }) => { +const ProfileMatches = ({ matches = defaultData, onRemove, remove, params: {username} }) => { const askToRemove = (match) => (event) => { const params = { title: 'Are you sure?', @@ -25,13 +26,20 @@ const ProfileMatches = ({ matches = {list: [], page: 1, totalPages:1}, onRemove, onRemove(params); event.preventDefault(); }; + console.log(matches.count); return (

Matches

{ matches.list ? - : + : }
diff --git a/src/profile/profile.reducer.js b/src/profile/profile.reducer.js index 53164ba..220b4fa 100644 --- a/src/profile/profile.reducer.js +++ b/src/profile/profile.reducer.js @@ -1,8 +1,9 @@ import * as types from './profile.types'; import * as authTypes from '../shared/auth/auth.types'; import * as MatchTypes from '../matches/match.types'; +import {defaultData} from '../matches/matches.reducer'; -const matches = (state = { page: 1, totalPages: 1, list: [] }, action) => { +const matches = (state = defaultData, action) => { switch (action.type) { case types.RECEIVE_MATCHES: return { @@ -10,6 +11,7 @@ const matches = (state = { page: 1, totalPages: 1, list: [] }, action) => { list: action.response.results, page: parseInt(action.response.page, 10), totalPages: Math.ceil(action.response.count / action.response.page_size), + count: action.response.count, }; case MatchTypes.DELETED: return { diff --git a/src/ranking/ranking.reducer.js b/src/ranking/ranking.reducer.js index 166cb52..f585e80 100644 --- a/src/ranking/ranking.reducer.js +++ b/src/ranking/ranking.reducer.js @@ -2,7 +2,6 @@ import * as types from '../users/user.types'; const model = { "desktop": { - "id": "ID", "username": "Username", "first_name": "First name", "last_name": "Last name", From 343abe1fd71e5cc65623b68deac3fabdaa7f1ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 21:56:07 +0100 Subject: [PATCH 20/60] Improve settings layout --- src/settings/components/MemberSettings.js | 28 ----- src/settings/components/PendingMemberList.js | 59 ++++----- src/settings/components/ProfileSettings.js | 85 ++++++++----- .../components/ProfileSettingsForm.js | 37 ++++++ src/settings/components/SettingsLayout.js | 116 +++++++----------- src/settings/components/TeamSettings.js | 29 +++++ 6 files changed, 192 insertions(+), 162 deletions(-) delete mode 100644 src/settings/components/MemberSettings.js create mode 100644 src/settings/components/ProfileSettingsForm.js create mode 100644 src/settings/components/TeamSettings.js diff --git a/src/settings/components/MemberSettings.js b/src/settings/components/MemberSettings.js deleted file mode 100644 index 55becb3..0000000 --- a/src/settings/components/MemberSettings.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react' -import InputField from './InputField'; -import { Form, FormGroup, Button, Col } from 'react-bootstrap'; - -const MemberSettings = ({saveMember, handleChange, username}) => { - return ( -
-
- Personal - - - - - - -
-
- ); -}; - -export default MemberSettings; diff --git a/src/settings/components/PendingMemberList.js b/src/settings/components/PendingMemberList.js index a4a6a2f..53a2842 100644 --- a/src/settings/components/PendingMemberList.js +++ b/src/settings/components/PendingMemberList.js @@ -1,39 +1,30 @@ import React from 'react' import {ListGroupItem, ListGroup, Row, Col, Button, ButtonToolbar, Glyphicon} from 'react-bootstrap'; -const PendingMemberList = ({users = [], onAccept, onReject}) => { - if (!users || users.length === 0) { - return null; - } - return ( -
-

Pending members

- - { - users.map( - user => - - - - {user.username} ({user.email}) - - - - - - - - - - ) - } - -
- ); -}; +const PendingMemberItem = ({user, onAccept, onReject}) => ( + + + + {user.username} ({user.email}) + + + + + + + + + +); + +const PendingMemberList = ({users = [], onAccept, onReject}) => ( + + { users.map(user => ) } + +); export default PendingMemberList; diff --git a/src/settings/components/ProfileSettings.js b/src/settings/components/ProfileSettings.js index a70ca25..ec36076 100644 --- a/src/settings/components/ProfileSettings.js +++ b/src/settings/components/ProfileSettings.js @@ -1,34 +1,57 @@ -import React from 'react' -import InputField from './InputField'; -import { Form, FormGroup, Button, Col } from 'react-bootstrap'; - -const ProfileSettings = ({saveProfile, first_name, last_name, handleChange}) => { - return ( -
-
- Profile data - - - - - +import React from 'react'; +import {Panel, Row, Col} from 'react-bootstrap'; +import ProfileSettingsForm from './ProfileSettingsForm'; + +class ProfileSettings extends React.Component { + constructor(props) { + super(props); + const {profile: {first_name, last_name, username}} = this.props; + + this.state = { + username, + first_name, + last_name, + }; + } + + handleChange = (fieldName) => (event) => { + this.setState({[fieldName]: event.target.value}); + }; + + saveProfile = (event) => { + event.preventDefault(); + const profileData = { + first_name: this.state.first_name, + last_name: this.state.last_name, + }; + const memberData = { + username: this.state.username, + }; + this.props.saveProfile(profileData); + this.props.saveMember(memberData); + }; + + + render() { + const { username, first_name, last_name } = this.state; + + return ( + + + +

Personal data

+ -
-
-
- ); -}; +
+ + ); + } +} export default ProfileSettings; diff --git a/src/settings/components/ProfileSettingsForm.js b/src/settings/components/ProfileSettingsForm.js new file mode 100644 index 0000000..a27e47c --- /dev/null +++ b/src/settings/components/ProfileSettingsForm.js @@ -0,0 +1,37 @@ +import React from 'react' +import InputField from './InputField'; +import {Form, FormGroup, Button, Col} from 'react-bootstrap'; + +const ProfileSettingsForm = ({saveProfile, first_name, last_name, username, handleChange}) => { + return ( +
+ + + + + + + + + + ); +}; + +export default ProfileSettingsForm; diff --git a/src/settings/components/SettingsLayout.js b/src/settings/components/SettingsLayout.js index 6150eec..fc348fe 100644 --- a/src/settings/components/SettingsLayout.js +++ b/src/settings/components/SettingsLayout.js @@ -1,14 +1,17 @@ import React from 'react'; import {connect} from 'react-redux'; -import {Row, Col, Well} from 'react-bootstrap'; +import {Row, Col, Tab, NavItem, Nav} from 'react-bootstrap'; import {requestSaveMember, requestSaveProfile} from '../settings.actions'; -import {getSelectedTeam} from '../../shared/teams/teams.reducer'; import { memberAcceptance } from '../../shared/teams/teams.actions'; -import MemberSettings from './MemberSettings'; import ProfileSettings from './ProfileSettings'; -import PendingMemberList from './PendingMemberList'; +import TeamSettings from './TeamSettings'; +import {getSelectedTeam} from '../../shared/teams/teams.reducer'; -const mapStateToProps = ({auth: {profile}, teams}) => ({profile, teams}); +const mapStateToProps = ({auth: {profile}, teams}) => ({ + profile, + currentTeam: getSelectedTeam(teams), + pendingTeams: teams.pending, +}); const mapDispatchToProps = (dispatch) => ({ saveMember: (data) => dispatch(requestSaveMember(data)), saveProfile: (data) => dispatch(requestSaveProfile(data)), @@ -18,76 +21,51 @@ const mapDispatchToProps = (dispatch) => ({ @connect(mapStateToProps, mapDispatchToProps) export default class SettingsLayout extends React.Component { - constructor(props) { - super(props); - const {profile: {first_name, last_name, username}, teams} = this.props; - const currentTeam = getSelectedTeam(teams); - this.state = { - username, - first_name, - last_name, - currentTeam, - }; - } - - handleChange = (fieldName) => (event) => { - this.setState({[fieldName]: event.target.value}); - }; - - saveMember = (event) => { - event.preventDefault(); - const data = { - username: this.state.username - }; - this.props.saveMember(data); - }; - - saveProfile = (event) => { - event.preventDefault(); - const data = { - first_name: this.state.first_name, - last_name: this.state.last_name, - }; - this.props.saveProfile(data); - }; - render() { - const {currentTeam, username, first_name, last_name} = this.state; - const {teams: {pending}, rejectMember, acceptMember} = this.props; + const { + pendingTeams, + currentTeam, + profile, + rejectMember, + acceptMember, + saveProfile, + saveMember + } = this.props; return (

Settings 

- - - - - - - - -

- Team membership ({ currentTeam ? currentTeam.name : ''}) -

- - -
- -
+ + + + + + + + + + + + + + + + + + {this.props.children}
); diff --git a/src/settings/components/TeamSettings.js b/src/settings/components/TeamSettings.js new file mode 100644 index 0000000..b425893 --- /dev/null +++ b/src/settings/components/TeamSettings.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { Panel} from 'react-bootstrap'; +import PendingMemberList from './PendingMemberList'; + +class TeamSettings extends React.Component { + constructor(props) { + super(props); + } + + render() { + const { pendingTeams, acceptMember, rejectMember, currentTeam } = this.props; + return ( + +

Team: {currentTeam.name}

+

Pending team members

+ { pendingTeams.length > 0 ? + : +
There are no pending team members
+ } +
+ ); + } +} + +export default TeamSettings; From e38fb5bb61fc0176a560b6cfada11c4ab1d444a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 22:12:46 +0100 Subject: [PATCH 21/60] Fix profile chart aspect ratio on mobile devices --- src/assets/css/styles.css | 6 ++++++ src/profile/components/ProfileChart.js | 8 ++++++-- src/profile/components/ProfileMatches.js | 1 - 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/assets/css/styles.css b/src/assets/css/styles.css index 2a53b2d..ba6ab2b 100644 --- a/src/assets/css/styles.css +++ b/src/assets/css/styles.css @@ -72,4 +72,10 @@ tr.selected { .alert { border-radius: 0 !important; margin-top: -21px; +} + +@media (max-width:768px) { + #profileChartWrapper { + height: 300px; + } } \ No newline at end of file diff --git a/src/profile/components/ProfileChart.js b/src/profile/components/ProfileChart.js index b019ce1..4977c3b 100644 --- a/src/profile/components/ProfileChart.js +++ b/src/profile/components/ProfileChart.js @@ -21,7 +21,9 @@ export default class ProfileChart extends Component { display: false, } }] - } + }, + responsive: true, + maintainAspectRatio: false, }; const ctx = this.chartDOM; const { profile: { exp_history } } = this.props; @@ -69,7 +71,9 @@ export default class ProfileChart extends Component { Object.keys(profile).length === 0 ? : exp_history ? - { this.chartDOM = chart; }} /> : +
+ { this.chartDOM = chart; }} /> +
:

Sorry, user has no experience points history.

} diff --git a/src/profile/components/ProfileMatches.js b/src/profile/components/ProfileMatches.js index edf51e0..31d0859 100644 --- a/src/profile/components/ProfileMatches.js +++ b/src/profile/components/ProfileMatches.js @@ -26,7 +26,6 @@ const ProfileMatches = ({ matches = defaultData, onRemove, remove, params: {user onRemove(params); event.preventDefault(); }; - console.log(matches.count); return (

Matches

From f0119914726253600525922ecd2d4a47f27d9680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Thu, 23 Mar 2017 23:10:46 +0100 Subject: [PATCH 22/60] Minor settings layout changes --- src/settings/components/ProfileSettings.js | 2 +- src/settings/components/TeamSettings.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/settings/components/ProfileSettings.js b/src/settings/components/ProfileSettings.js index ec36076..bc41110 100644 --- a/src/settings/components/ProfileSettings.js +++ b/src/settings/components/ProfileSettings.js @@ -39,7 +39,7 @@ class ProfileSettings extends React.Component { -

Personal data

+

Personal data

Team: {currentTeam.name}

-

Pending team members

+
+

Pending team members

{ pendingTeams.length > 0 ? Date: Fri, 24 Mar 2017 21:19:29 +0100 Subject: [PATCH 23/60] Minor layout fixes --- src/assets/css/styles.css | 8 +++----- src/index.js | 12 +++++++++++- src/matches/components/MatchesLayout.js | 4 ++-- src/ranking/components/RankingLayout.js | 4 ++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/assets/css/styles.css b/src/assets/css/styles.css index ba6ab2b..aca757c 100644 --- a/src/assets/css/styles.css +++ b/src/assets/css/styles.css @@ -74,8 +74,6 @@ tr.selected { margin-top: -21px; } -@media (max-width:768px) { - #profileChartWrapper { - height: 300px; - } -} \ No newline at end of file +#profileChartWrapper { + height: 300px; +} diff --git a/src/index.js b/src/index.js index 17d0fd4..f988e21 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,16 @@ function requireAuth(nextState, replace, next) { } next(); } +function homepage(nextState, replace, next) { + const persistedState = loadState(); + if (persistedState.hasOwnProperty('auth') && persistedState.auth.hasOwnProperty('token')) { + replace({ + pathname: "/match", + state: {nextPathname: nextState.location.pathname} + }); + } + next(); +} function hasTeams(nextState, replace, next) { const persistedState = loadState(); @@ -50,7 +60,7 @@ ReactDOM.render( - + diff --git a/src/matches/components/MatchesLayout.js b/src/matches/components/MatchesLayout.js index 8179649..a520cef 100644 --- a/src/matches/components/MatchesLayout.js +++ b/src/matches/components/MatchesLayout.js @@ -16,13 +16,13 @@ export default class MatchesLayout extends React.Component { return ( - +

Matches

- + diff --git a/src/ranking/components/RankingLayout.js b/src/ranking/components/RankingLayout.js index 0d4999c..4ac73f6 100644 --- a/src/ranking/components/RankingLayout.js +++ b/src/ranking/components/RankingLayout.js @@ -21,12 +21,12 @@ export default class RankingLayout extends React.Component { return ( - +

Ranking

- + From a937fa4b5a62173351db231b4fa9e423959001f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stani=C3=B3w?= Date: Fri, 24 Mar 2017 22:58:45 +0100 Subject: [PATCH 24/60] Refactor Header to fit nicely on mobile. Get and store expires_at from sign in response. --- src/index.js | 4 ++- src/shared/auth/auth.actions.js | 3 +- src/shared/auth/auth.reducer.js | 1 + src/shared/auth/auth.sagas.js | 4 +-- src/shared/components/Header.js | 44 +++++++++------------------ src/shared/components/Navigation.jsx | 24 +++++++++++++++ src/shared/components/SignInButton.js | 14 ++++----- 7 files changed, 54 insertions(+), 40 deletions(-) create mode 100644 src/shared/components/Navigation.jsx diff --git a/src/index.js b/src/index.js index f988e21..54e3ecf 100644 --- a/src/index.js +++ b/src/index.js @@ -30,7 +30,9 @@ function requireAuth(nextState, replace, next) { } function homepage(nextState, replace, next) { const persistedState = loadState(); - if (persistedState.hasOwnProperty('auth') && persistedState.auth.hasOwnProperty('token')) { + if (persistedState && + persistedState.hasOwnProperty('auth') && + persistedState.auth.hasOwnProperty('token')) { replace({ pathname: "/match", state: {nextPathname: nextState.location.pathname} diff --git a/src/shared/auth/auth.actions.js b/src/shared/auth/auth.actions.js index 8e93a75..a9f2e15 100644 --- a/src/shared/auth/auth.actions.js +++ b/src/shared/auth/auth.actions.js @@ -1,8 +1,9 @@ import * as types from './auth.types'; -export const setToken = (token) => ({ +export const setToken = (token, expires_at) => ({ type: types.SET_TOKEN, token, + expires_at, }); export const setProfile = (response) => ({ diff --git a/src/shared/auth/auth.reducer.js b/src/shared/auth/auth.reducer.js index a52968e..084df47 100644 --- a/src/shared/auth/auth.reducer.js +++ b/src/shared/auth/auth.reducer.js @@ -17,6 +17,7 @@ export const auth = (state = {}, action) => { return { ...state, token: action.token, + expires_at: action.expires_at, }; case types.SIGNED_OUT: return {}; diff --git a/src/shared/auth/auth.sagas.js b/src/shared/auth/auth.sagas.js index a7a7305..8aeb51b 100644 --- a/src/shared/auth/auth.sagas.js +++ b/src/shared/auth/auth.sagas.js @@ -14,8 +14,8 @@ export function* authenticate() { if (token) return { token }; const promptWindow = prepareWindow(); try { - const { token } = yield call([promptWindow, promptWindow.open]); - yield put(setToken(token)); + const { token, expires_at } = yield call([promptWindow, promptWindow.open]); + yield put(setToken(token, expires_at)); return { token }; } catch (error) { const errorMsg = getOAuthErrorMsg(error); diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 8043106..ca1ea8d 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -2,14 +2,20 @@ import React from 'react'; import { signIn, signOut } from '../auth/auth.actions'; import { selectTeam } from '../teams/teams.actions'; import { getSelectedTeam } from '../teams/teams.reducer'; -import { Navbar, Nav, NavItem } from 'react-bootstrap'; -import { LinkContainer } from 'react-router-bootstrap'; +import { Navbar, Nav } from 'react-bootstrap'; import SignInButton from './SignInButton'; import { connect } from 'react-redux'; import HeaderDropdown from './HeaderDropdown'; import Notifications from './Notifications'; +import Navigation from './Navigation'; -const mapStateToProps = ({auth, teams}) => ({auth, teams}); + +const mapStateToProps = ({auth: {profile, token}, teams}) => ({ + username: profile && profile.hasOwnProperty('username') ? profile.username : '', + isAuthenticated: !!token, + currentTeam: getSelectedTeam(teams), + joinedTeams: teams.joined, +}); const mapDispatchToProps = dispatch => ({ signIn: () => dispatch(signIn()), signOut: () => dispatch(signOut()), @@ -18,32 +24,12 @@ const mapDispatchToProps = dispatch => ({ @connect(mapStateToProps, mapDispatchToProps) export default class Header extends React.Component { - renderNavigation(username) { - return ( - - ); - } - render() { - const { auth: {token, profile}, teams, signIn, signOut, selectTeam } = this.props; - const currentTeam = getSelectedTeam(teams); - const username = profile && profile.hasOwnProperty('username') ? profile.username : ''; + const { signIn, signOut, selectTeam } = this.props; + const { username, currentTeam, isAuthenticated, joinedTeams } = this.props; return (
- + TFoosball @@ -51,14 +37,14 @@ export default class Header extends React.Component { - { username ? this.renderNavigation(username) : null } + { username ? : null }