diff --git a/.gitignore b/.gitignore index 2af11b2e..983ad299 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __coverage__ dist node_modules _auto_doc_ +.vscode \ No newline at end of file diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 67d964da..69a9d410 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -48,6 +48,9 @@ Object { "getGroupsDone": [Function], "getGroupsInit": [Function], }, + "lookup": Object { + "getApprovedSkills": [Function], + }, "memberTasks": Object { "dropAll": [Function], "getDone": [Function], @@ -64,19 +67,49 @@ Object { "getStatsInit": [Function], }, "profile": Object { + "addSkillDone": [Function], + "addSkillInit": [Function], + "addWebLinkDone": [Function], + "addWebLinkInit": [Function], + "deletePhotoDone": [Function], + "deletePhotoInit": [Function], + "deleteWebLinkDone": [Function], + "deleteWebLinkInit": [Function], "getAchievementsDone": [Function], "getAchievementsInit": [Function], + "getActiveChallengesCountDone": [Function], + "getActiveChallengesCountInit": [Function], + "getCredentialDone": [Function], + "getCredentialInit": [Function], + "getEmailPreferencesDone": [Function], + "getEmailPreferencesInit": [Function], "getExternalAccountsDone": [Function], "getExternalAccountsInit": [Function], "getExternalLinksDone": [Function], "getExternalLinksInit": [Function], "getInfoDone": [Function], "getInfoInit": [Function], + "getLinkedAccountsDone": [Function], + "getLinkedAccountsInit": [Function], "getSkillsDone": [Function], "getSkillsInit": [Function], "getStatsDone": [Function], "getStatsInit": [Function], + "hideSkillDone": [Function], + "hideSkillInit": [Function], + "linkExternalAccountDone": [Function], + "linkExternalAccountInit": [Function], "loadProfile": [Function], + "saveEmailPreferencesDone": [Function], + "saveEmailPreferencesInit": [Function], + "unlinkExternalAccountDone": [Function], + "unlinkExternalAccountInit": [Function], + "updatePasswordDone": [Function], + "updatePasswordInit": [Function], + "updateProfileDone": [Function], + "updateProfileInit": [Function], + "uploadPhotoDone": [Function], + "uploadPhotoInit": [Function], }, "reviewOpportunity": Object { "cancelApplicationsDone": [Function], @@ -159,6 +192,21 @@ Object { "default": undefined, "mockAction": [Function], }, + "reducerFactories": Object { + "authFactory": [Function], + "challengeFactory": [Function], + "directFactory": [Function], + "errorsFactory": [Function], + "groupsFactory": [Function], + "lookupFactory": [Function], + "memberTasksFactory": [Function], + "membersFactory": [Function], + "mySubmissionsManagementFactory": [Function], + "profileFactory": [Function], + "reviewOpportunityFactory": [Function], + "statsFactory": [Function], + "termsFactory": [Function], + }, "reducerFactory": [Function], "reducers": Object { "auth": [Function], @@ -166,6 +214,7 @@ Object { "direct": [Function], "errors": [Function], "groups": [Function], + "lookup": [Function], "memberTasks": [Function], "members": [Function], "mySubmissionsManagement": [Function], @@ -209,6 +258,10 @@ Object { "default": undefined, "getService": [Function], }, + "lookup": Object { + "default": undefined, + "getService": [Function], + }, "members": Object { "default": undefined, "getService": [Function], @@ -242,6 +295,7 @@ Object { "Spec Review": "Specification Review", }, "getApiResponsePayloadV3": [Function], + "looseEqual": [Function], }, "time": Object { "default": undefined, diff --git a/__tests__/actions/lookup.js b/__tests__/actions/lookup.js new file mode 100644 index 00000000..f33f198f --- /dev/null +++ b/__tests__/actions/lookup.js @@ -0,0 +1,30 @@ +import * as LookupService from 'services/lookup'; +import actions from 'actions/lookup'; + +const tag = { + domain: 'SKILLS', + id: 251, + name: 'Jekyll', + status: 'APPROVED', +}; + +// Mock services +const mockLookupService = { + getTags: jest.fn().mockReturnValue(Promise.resolve([tag])), +}; +LookupService.getService = jest.fn().mockReturnValue(mockLookupService); + + +describe('lookup.getApprovedSkills', () => { + const a = actions.lookup.getApprovedSkills(); + + test('has expected type', () => { + expect(a.type).toEqual('LOOKUP/GET_APPROVED_SKILLS'); + }); + + test('Approved skills should be returned', () => + a.payload.then((res) => { + expect(res).toEqual([tag]); + expect(mockLookupService.getTags).toBeCalled(); + })); +}); diff --git a/__tests__/actions/profile.js b/__tests__/actions/profile.js new file mode 100644 index 00000000..3efdd708 --- /dev/null +++ b/__tests__/actions/profile.js @@ -0,0 +1,252 @@ +import * as ChallengesService from 'services/challenges'; +import * as MembersService from 'services/members'; +import * as UserService from 'services/user'; + +import actions from 'actions/profile'; + +const handle = 'tcscoder'; +const tokenV3 = 'tokenV3'; +const profile = { userId: 12345, handle }; +const skill = { tagId: 123, tagName: 'Node.js' }; +const weblink = 'https://www.google.com'; +const linkedAccounts = [{ + providerType: 'github', + social: true, + userId: '623633', +}]; + +// Mock services +const mockChanllengesService = { + getUserChallenges: jest.fn().mockReturnValue(Promise.resolve({ totalCount: 3 })), + getUserMarathonMatches: jest.fn().mockReturnValue(Promise.resolve({ totalCount: 5 })), +}; +ChallengesService.getService = jest.fn().mockReturnValue(mockChanllengesService); + +const mockMembersService = { + getPresignedUrl: jest.fn().mockReturnValue(Promise.resolve()), + uploadFileToS3: jest.fn().mockReturnValue(Promise.resolve()), + updateMemberPhoto: jest.fn().mockReturnValue(Promise.resolve('url-of-photo')), + updateMemberProfile: jest.fn().mockReturnValue(Promise.resolve(profile)), + addSkill: jest.fn().mockReturnValue(Promise.resolve({ skills: [skill] })), + hideSkill: jest.fn().mockReturnValue(Promise.resolve({ skills: [] })), + addWebLink: jest.fn().mockReturnValue(Promise.resolve(weblink)), + deleteWebLink: jest.fn().mockReturnValue(Promise.resolve(weblink)), +}; +MembersService.getService = jest.fn().mockReturnValue(mockMembersService); + +const mockUserService = { + linkExternalAccount: jest.fn().mockReturnValue(Promise.resolve(linkedAccounts[0])), + unlinkExternalAccount: jest.fn().mockReturnValue(Promise.resolve('unlinked')), + getLinkedAccounts: jest.fn().mockReturnValue(Promise.resolve({ profiles: linkedAccounts })), + getCredential: jest.fn().mockReturnValue(Promise.resolve({ credential: { hasPassword: true } })), + getEmailPreferences: + jest.fn().mockReturnValue(Promise.resolve({ subscriptions: { TOPCODER_NL_DATA: true } })), + saveEmailPreferences: + jest.fn().mockReturnValue(Promise.resolve({ subscriptions: { TOPCODER_NL_DATA: true } })), + updatePassword: jest.fn().mockReturnValue(Promise.resolve({ update: true })), +}; +UserService.getService = jest.fn().mockReturnValue(mockUserService); + + +describe('profile.getActiveChallengesCountDone', () => { + const a = actions.profile.getActiveChallengesCountDone(handle, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/GET_ACTIVE_CHALLENGES_COUNT_DONE'); + }); + + test('Sum of challenges and marathon matches should be returned', () => + a.payload.then((res) => { + expect(res).toBe(8); + expect(mockChanllengesService.getUserChallenges).toBeCalled(); + expect(mockChanllengesService.getUserMarathonMatches).toBeCalled(); + })); +}); + +describe('profile.uploadPhotoDone', () => { + const a = actions.profile.uploadPhotoDone(handle, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/UPLOAD_PHOTO_DONE'); + }); + + test('Photo URL should be returned', () => + a.payload.then((res) => { + expect(res).toEqual({ + handle, + photoURL: 'url-of-photo', + }); + expect(mockMembersService.getPresignedUrl).toBeCalled(); + expect(mockMembersService.uploadFileToS3).toBeCalled(); + expect(mockMembersService.updateMemberPhoto).toBeCalled(); + })); +}); + +describe('profile.updateProfileDone', () => { + const a = actions.profile.updateProfileDone(profile, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/UPDATE_PROFILE_DONE'); + }); + + test('Profile should be updated', () => + a.payload.then((res) => { + expect(res).toEqual(profile); + expect(mockMembersService.updateMemberProfile).toBeCalled(); + })); +}); + +describe('profile.addSkillDone', () => { + const a = actions.profile.addSkillDone(handle, tokenV3, skill); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/ADD_SKILL_DONE'); + }); + + test('Skill should be added', () => + a.payload.then((res) => { + expect(res).toEqual({ skills: [skill], handle, skill }); + expect(mockMembersService.addSkill).toBeCalled(); + })); +}); + +describe('profile.hideSkillDone', () => { + const a = actions.profile.hideSkillDone(handle, tokenV3, skill); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/HIDE_SKILL_DONE'); + }); + + test('Skill should be removed', () => + a.payload.then((res) => { + expect(res).toEqual({ skills: [], handle, skill }); + expect(mockMembersService.hideSkill).toBeCalled(); + })); +}); + +describe('profile.addWebLinkDone', () => { + const a = actions.profile.addWebLinkDone(handle, tokenV3, weblink); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/ADD_WEB_LINK_DONE'); + }); + + test('Web link should be added', () => + a.payload.then((res) => { + expect(res).toEqual({ data: weblink, handle }); + expect(mockMembersService.addWebLink).toBeCalled(); + })); +}); + +describe('profile.deleteWebLinkDone', () => { + const a = actions.profile.deleteWebLinkDone(handle, tokenV3, weblink); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/DELETE_WEB_LINK_DONE'); + }); + + test('Web link should be deleted', () => + a.payload.then((res) => { + expect(res).toEqual({ data: weblink, handle }); + expect(mockMembersService.deleteWebLink).toBeCalled(); + })); +}); + +describe('profile.linkExternalAccountDone', () => { + const a = actions.profile.linkExternalAccountDone(profile, tokenV3, 'github'); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/LINK_EXTERNAL_ACCOUNT_DONE'); + }); + + test('External account should be linked', () => + a.payload.then((res) => { + expect(res).toEqual({ data: linkedAccounts[0], handle }); + expect(mockUserService.linkExternalAccount).toBeCalled(); + })); +}); + +describe('profile.unlinkExternalAccountDone', () => { + const a = actions.profile.unlinkExternalAccountDone(profile, tokenV3, 'github'); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/UNLINK_EXTERNAL_ACCOUNT_DONE'); + }); + + test('External account should be unlinked', () => + a.payload.then((res) => { + expect(res).toEqual({ handle, providerType: 'github' }); + expect(mockUserService.unlinkExternalAccount).toBeCalled(); + })); +}); + +describe('profile.getLinkedAccountsDone', () => { + const a = actions.profile.getLinkedAccountsDone(profile, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/GET_LINKED_ACCOUNTS_DONE'); + }); + + test('Linked account should be returned', () => + a.payload.then((res) => { + expect(res).toEqual({ profiles: linkedAccounts }); + expect(mockUserService.getLinkedAccounts).toBeCalled(); + })); +}); + +describe('profile.getCredentialDone', () => { + const a = actions.profile.getCredentialDone(profile, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/GET_CREDENTIAL_DONE'); + }); + + test('Credential should be returned', () => + a.payload.then((res) => { + expect(res).toEqual({ credential: { hasPassword: true } }); + expect(mockUserService.getCredential).toBeCalled(); + })); +}); + +describe('profile.getEmailPreferencesDone', () => { + const a = actions.profile.getEmailPreferencesDone(profile, tokenV3); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/GET_EMAIL_PREFERENCES_DONE'); + }); + + test('Email preferences should be returned', () => + a.payload.then((res) => { + expect(res).toEqual({ subscriptions: { TOPCODER_NL_DATA: true } }); + expect(mockUserService.getEmailPreferences).toBeCalled(); + })); +}); + +describe('profile.saveEmailPreferencesDone', () => { + const a = actions.profile.saveEmailPreferencesDone(profile, tokenV3, {}); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/SAVE_EMAIL_PREFERENCES_DONE'); + }); + + test('Email preferences should be updated', () => + a.payload.then((res) => { + expect(res).toEqual({ handle, data: { subscriptions: { TOPCODER_NL_DATA: true } } }); + expect(mockUserService.saveEmailPreferences).toBeCalled(); + })); +}); + +describe('profile.updatePasswordDone', () => { + const a = actions.profile.updatePasswordDone(profile, tokenV3, 'newPassword', 'oldPassword'); + + test('has expected type', () => { + expect(a.type).toBe('PROFILE/UPDATE_PASSWORD_DONE'); + }); + + test('User password should be updated', () => + a.payload.then((res) => { + expect(res).toEqual({ handle, data: { update: true } }); + expect(mockUserService.updatePassword).toBeCalled(); + })); +}); + diff --git a/__tests__/reducers/auth.js b/__tests__/reducers/auth.js index a86137f7..f89ea226 100644 --- a/__tests__/reducers/auth.js +++ b/__tests__/reducers/auth.js @@ -2,6 +2,8 @@ import { mockAction } from 'utils/mock'; import { redux } from 'topcoder-react-utils'; const dummy = 'DUMMY'; +const handle = 'tcscoder'; +const photoURL = 'http://url'; const mockActions = { auth: { @@ -9,8 +11,14 @@ const mockActions = { setTcTokenV2: mockAction('SET_TC_TOKEN_V2', 'Token V2'), setTcTokenV3: mockAction('SET_TC_TOKEN_V3', 'Token V3'), }, + profile: { + uploadPhotoDone: mockAction('UPLOAD_PHOTO_DONE', Promise.resolve({ handle, photoURL })), + deletePhotoDone: mockAction('DELETE_PHOTO_DONE', Promise.resolve({ handle })), + updateProfileDone: mockAction('UPDATE_PROFILE_DONE', Promise.resolve({ handle, photoURL: 'http://newurl' })), + }, }; jest.setMock(require.resolve('actions/auth'), mockActions); +jest.setMock(require.resolve('actions/profile'), mockActions); jest.setMock('tc-accounts', { decodeToken: () => 'User object', @@ -19,7 +27,9 @@ jest.setMock('tc-accounts', { const reducers = require('reducers/auth'); -function testReducer(reducer, istate) { +let reducer; + +function testReducer(istate) { test('Initial state', () => { const state = reducer(undefined, {}); expect(state).toEqual(istate); @@ -62,10 +72,61 @@ function testReducer(reducer, istate) { }); mockActions.auth.setTcTokenV3 = mockAction('SET_TC_TOKEN_V3', 'Token V3'); }); + + test('Upload photo', () => + redux.resolveAction(mockActions.profile.uploadPhotoDone()).then((action) => { + const state = reducer({ profile: { handle } }, action); + expect(state).toEqual({ + profile: { + handle, + photoURL, + }, + }); + })); + + test('Delete photo', () => + redux.resolveAction(mockActions.profile.deletePhotoDone()).then((action) => { + const state = reducer({ profile: { handle, photoURL } }, action); + expect(state).toEqual({ + profile: { + handle, + photoURL: null, + }, + }); + })); + + test('Update profile', () => + redux.resolveAction(mockActions.profile.updateProfileDone()).then((action) => { + const state = reducer({ profile: { handle, photoURL } }, action); + expect(state).toEqual({ + profile: { + handle, + photoURL: 'http://newurl', + }, + }); + })); } describe('Default reducer', () => { - testReducer(reducers.default, { + reducer = reducers.default; + testReducer({ + authenticating: true, + profile: null, + tokenV2: '', + tokenV3: '', + user: null, + }); +}); + +describe('Factory without server side rendering', () => { + beforeAll((done) => { + reducers.factory().then((res) => { + reducer = res; + done(); + }); + }); + + testReducer({ authenticating: true, profile: null, tokenV2: '', @@ -74,15 +135,20 @@ describe('Default reducer', () => { }); }); -describe('Factory without server side rendering', () => - reducers.factory().then(res => - testReducer(res, {}))); - -describe('Factory with server side rendering', () => - reducers.factory({ - cookies: { - tcjwt: 'Token V2', - v3jwt: 'Token V3', - }, - }).then(res => - testReducer(res, {}))); +describe('Factory with server side rendering', () => { + beforeAll((done) => { + reducers.factory({ + auth: { + tokenV2: 'Token V2', + tokenV3: 'Token V3', + }, + }).then((res) => { + reducer = res; + done(); + }); + }); + + testReducer({ + authenticating: false, user: 'User object', profile: 'Profile', tokenV2: 'Token V2', tokenV3: 'Token V3', + }); +}); diff --git a/__tests__/reducers/lookup.js b/__tests__/reducers/lookup.js new file mode 100644 index 00000000..6e30d571 --- /dev/null +++ b/__tests__/reducers/lookup.js @@ -0,0 +1,59 @@ +import { mockAction } from 'utils/mock'; +import { redux } from 'topcoder-react-utils'; + +const tag = { + domain: 'SKILLS', + id: 251, + name: 'Jekyll', + status: 'APPROVED', +}; + +const mockActions = { + lookup: { + getApprovedSkills: mockAction('LOOKUP/GET_APPROVED_SKILLS', Promise.resolve([tag])), + getApprovedSkillsError: mockAction('LOOKUP/GET_APPROVED_SKILLS', null, 'Unknown error'), + }, +}; +jest.setMock(require.resolve('actions/lookup'), mockActions); + +const reducers = require('reducers/lookup'); + +let reducer; + +function testReducer(istate) { + test('Initial state', () => { + const state = reducer(undefined, {}); + expect(state).toEqual(istate); + }); + + test('Load approved skills', () => + redux.resolveAction(mockActions.lookup.getApprovedSkills()).then((action) => { + const state = reducer({}, action); + expect(state).toEqual({ + approvedSkills: [tag], + loadingApprovedSkillsError: false, + }); + })); + + test('Load approved skills error', () => { + const state = reducer({}, mockActions.lookup.getApprovedSkillsError()); + expect(state).toEqual({ + loadingApprovedSkillsError: true, + }); + }); +} + +describe('Default reducer', () => { + reducer = reducers.default; + testReducer({ approvedSkills: [] }); +}); + +describe('Factory without server side rendering', () => { + beforeAll((done) => { + reducers.factory().then((res) => { + reducer = res; + done(); + }); + }); + testReducer({ approvedSkills: [] }); +}); diff --git a/__tests__/reducers/profile.js b/__tests__/reducers/profile.js new file mode 100644 index 00000000..f805ed11 --- /dev/null +++ b/__tests__/reducers/profile.js @@ -0,0 +1,274 @@ +import { mockAction } from 'utils/mock'; + +const handle = 'tcscoder'; +const photoURL = 'http://url'; +const skill = { tagId: 123, tagName: 'Node.js' }; +const externalLink = { providerType: 'weblink', key: '1111', URL: 'http://www.github.com' }; +const webLink = { providerType: 'weblink', key: '2222', URL: 'http://www.google.com' }; +const linkedAccount = { providerType: 'github', social: true, userId: '623633' }; +const linkedAccount2 = { providerType: 'stackoverlow', social: true, userId: '343523' }; + +const mockActions = { + profile: { + loadProfile: mockAction('LOAD_PROFILE', handle), + getInfoDone: mockAction('GET_INFO_DONE', { handle }), + getExternalLinksDone: mockAction('GET_EXTERNAL_LINKS_DONE', [externalLink]), + getActiveChallengesCountDone: mockAction('GET_ACTIVE_CHALLENGES_COUNT_DONE', 5), + getLinkedAccountsDone: mockAction('GET_LINKED_ACCOUNTS_DONE', { profiles: [linkedAccount] }), + getCredentialDone: mockAction('GET_CREDENTIAL_DONE', { credential: { hasPassword: true } }), + getEmailPreferencesDone: mockAction('GET_EMAIL_PREFERENCES_DONE', { subscriptions: { TOPCODER_NL_DATA: true } }), + uploadPhotoInit: mockAction('UPLOAD_PHOTO_INIT'), + uploadPhotoDone: mockAction('UPLOAD_PHOTO_DONE', { handle, photoURL }), + deletePhotoInit: mockAction('DELETE_PHOTO_INIT'), + deletePhotoDone: mockAction('DELETE_PHOTO_DONE', { handle }), + updatePasswordInit: mockAction('UPDATE_PASSWORD_INIT'), + updatePasswordDone: mockAction('UPDATE_PASSWORD_DONE'), + updateProfileInit: mockAction('UPDATE_PROFILE_INIT'), + updateProfileDone: mockAction('UPDATE_PROFILE_DONE', { handle, description: 'bio desc' }), + addSkillInit: mockAction('ADD_SKILL_INIT'), + addSkillDone: mockAction('ADD_SKILL_DONE', { handle, skills: [skill] }), + hideSkillInit: mockAction('HIDE_SKILL_INIT'), + hideSkillDone: mockAction('HIDE_SKILL_DONE', { handle, skills: [] }), + addWebLinkInit: mockAction('ADD_WEB_LINK_INIT'), + addWebLinkDone: mockAction('ADD_WEB_LINK_DONE', { handle, data: webLink }), + deleteWebLinkInit: mockAction('DELETE_WEB_LINK_INIT'), + deleteWebLinkDone: mockAction('DELETE_WEB_LINK_DONE', { handle, data: webLink }), + saveEmailPreferencesInit: mockAction('SAVE_EMAIL_PREFERENCES_INIT'), + saveEmailPreferencesDone: mockAction('SAVE_EMAIL_PREFERENCES_DONE', { handle, data: { subscriptions: { TOPCODER_NL_DATA: true } } }), + linkExternalAccountInit: mockAction('LINK_EXTERNAL_ACCOUNT_INIT'), + linkExternalAccountDone: mockAction('LINK_EXTERNAL_ACCOUNT_DONE', { handle, data: linkedAccount2 }), + unlinkExternalAccountInit: mockAction('UNLINK_EXTERNAL_ACCOUNT_INIT'), + unlinkExternalAccountDone: mockAction('UNLINK_EXTERNAL_ACCOUNT_DONE', { handle, providerType: linkedAccount2.providerType }), + }, +}; +jest.setMock(require.resolve('actions/profile'), mockActions); + +const reducers = require('reducers/profile'); + +let reducer; + +function testReducer(istate) { + let state; + + test('Initial state', () => { + state = reducer(undefined, {}); + expect(state).toEqual(istate); + }); + + test('Load profile', () => { + const prevState = state; + state = reducer(state, mockActions.profile.loadProfile()); + expect(state).toEqual({ ...prevState, profileForHandle: handle }); + }); + + test('Get member info', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getInfoDone()); + expect(state).toEqual({ ...prevState, info: { handle } }); + }); + + test('Get external links', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getExternalLinksDone()); + expect(state).toEqual({ ...prevState, externalLinks: [externalLink] }); + }); + + test('Get active challenges count', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getActiveChallengesCountDone()); + expect(state).toEqual({ ...prevState, activeChallengesCount: 5 }); + }); + + test('Get linked account', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getLinkedAccountsDone()); + expect(state).toEqual({ ...prevState, linkedAccounts: [linkedAccount] }); + }); + + test('Get credential', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getCredentialDone()); + expect(state).toEqual({ ...prevState, credential: { hasPassword: true } }); + }); + + test('Get email preferences', () => { + const prevState = state; + state = reducer(state, mockActions.profile.getEmailPreferencesDone()); + expect(state).toEqual({ ...prevState, emailPreferences: { TOPCODER_NL_DATA: true } }); + }); + + test('Upload photo init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.uploadPhotoInit()); + expect(state).toEqual({ ...prevState, uploadingPhoto: true }); + }); + + test('Upload photo done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.uploadPhotoDone()); + expect(state).toEqual({ ...prevState, info: { handle, photoURL }, uploadingPhoto: false }); + }); + + test('Delete photo init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.deletePhotoInit()); + expect(state).toEqual({ ...prevState, deletingPhoto: true }); + }); + + test('Delete photo done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.deletePhotoDone()); + expect(state).toEqual({ ...prevState, info: { handle, photoURL: null }, deletingPhoto: false }); + }); + + test('Update profile init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.updateProfileInit()); + expect(state).toEqual({ ...prevState, updatingProfile: true }); + }); + + test('Update profile done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.updateProfileDone()); + expect(state).toEqual({ ...prevState, info: { handle, photoURL: null, description: 'bio desc' }, updatingProfile: false }); + }); + + test('Add skill init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.addSkillInit()); + expect(state).toEqual({ ...prevState, addingSkill: true }); + }); + + test('Add skill done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.addSkillDone()); + expect(state).toEqual({ ...prevState, skills: [skill], addingSkill: false }); + }); + + test('Hide skill init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.hideSkillInit()); + expect(state).toEqual({ ...prevState, hidingSkill: true }); + }); + + test('Hide skill done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.hideSkillDone()); + expect(state).toEqual({ ...prevState, skills: [], hidingSkill: false }); + }); + + test('Add web link init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.addWebLinkInit()); + expect(state).toEqual({ ...prevState, addingWebLink: true }); + }); + + test('Add web link done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.addWebLinkDone()); + expect(state).toEqual({ + ...prevState, + externalLinks: [externalLink, webLink], + addingWebLink: false, + }); + }); + + test('Delete web link init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.deleteWebLinkInit()); + expect(state).toEqual({ ...prevState, deletingWebLink: true }); + }); + + test('Delete web link done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.deleteWebLinkDone()); + expect(state).toEqual({ ...prevState, externalLinks: [externalLink], deletingWebLink: false }); + }); + + test('Link external account init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.linkExternalAccountInit()); + expect(state).toEqual({ ...prevState, linkingExternalAccount: true }); + }); + + test('Link external account done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.linkExternalAccountDone()); + expect(state).toEqual({ + ...prevState, + linkedAccounts: [linkedAccount, linkedAccount2], + linkingExternalAccount: false, + }); + }); + + test('Unlink external account init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.unlinkExternalAccountInit()); + expect(state).toEqual({ ...prevState, unlinkingExternalAccount: true }); + }); + + test('Unlink external account done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.unlinkExternalAccountDone()); + expect(state).toEqual({ + ...prevState, + linkedAccounts: [linkedAccount], + unlinkingExternalAccount: false, + }); + }); + + test('Save email preferences init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.saveEmailPreferencesInit()); + expect(state).toEqual({ ...prevState, savingEmailPreferences: true }); + }); + + test('Save email preferences done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.saveEmailPreferencesDone()); + expect(state).toEqual({ + ...prevState, + emailPreferences: { TOPCODER_NL_DATA: true }, + savingEmailPreferences: false, + }); + }); + + test('Update password init', () => { + const prevState = state; + state = reducer(state, mockActions.profile.updatePasswordInit()); + expect(state).toEqual({ ...prevState, updatingPassword: true }); + }); + + test('Update password done', () => { + const prevState = state; + state = reducer(state, mockActions.profile.updatePasswordDone()); + expect(state).toEqual({ ...prevState, updatingPassword: false }); + }); +} + +const defaultState = { + achievements: null, + copilot: false, + country: '', + info: null, + loadingError: false, + skills: null, + stats: null, +}; + +describe('Default reducer', () => { + reducer = reducers.default; + testReducer(defaultState); +}); + +describe('Factory without server side rendering', () => { + beforeAll((done) => { + reducers.factory().then((res) => { + reducer = res; + done(); + }); + }); + + testReducer(defaultState); +}); + diff --git a/docs/actions.lookup.md b/docs/actions.lookup.md new file mode 100644 index 00000000..00193737 --- /dev/null +++ b/docs/actions.lookup.md @@ -0,0 +1,11 @@ + + +## actions.lookup +Actions related to lookup data. + + + +### actions.lookup.getApprovedSkills() ⇒ Action +Gets approved skill tags. + +**Kind**: static method of [actions.lookup](#module_actions.lookup) diff --git a/docs/actions.profile.md b/docs/actions.profile.md index 14e5b1fd..9f0899b0 100644 --- a/docs/actions.profile.md +++ b/docs/actions.profile.md @@ -23,6 +23,35 @@ Actions for interactions with profile details API. * [.getSkillsDone(handle)](#module_actions.profile.getSkillsDone) ⇒ Action * [.getStatsInit()](#module_actions.profile.getStatsInit) ⇒ Action * [.getStatsDone(handle)](#module_actions.profile.getStatsDone) ⇒ Action + * [.getActiveChallengesCountInit()](#module_actions.profile.getActiveChallengesCountInit) ⇒ Action + * [.getActiveChallengesCountDone(handle, tokenV3)](#module_actions.profile.getActiveChallengesCountDone) ⇒ Action + * [.getLinkedAccountsInit()](#module_actions.profile.getLinkedAccountsInit) ⇒ Action + * [.getLinkedAccountsDone(profile, tokenV3)](#module_actions.profile.getLinkedAccountsDone) ⇒ Action + * [.getCredentialInit()](#module_actions.profile.getCredentialInit) ⇒ Action + * [.getCredentialDone(profile, tokenV3)](#module_actions.profile.getCredentialDone) ⇒ Action + * [.getEmailPreferencesInit()](#module_actions.profile.getEmailPreferencesInit) ⇒ Action + * [.getEmailPreferencesDone(profile, tokenV3)](#module_actions.profile.getEmailPreferencesDone) ⇒ Action + * [.uploadPhotoInit()](#module_actions.profile.uploadPhotoInit) ⇒ Action + * [.uploadPhotoDone(handle, tokenV3, file)](#module_actions.profile.uploadPhotoDone) ⇒ Action + * [.deletePhotoInit()](#module_actions.profile.deletePhotoInit) ⇒ Action + * [.updateProfileInit()](#module_actions.profile.updateProfileInit) ⇒ Action + * [.updateProfileDone(profile, tokenV3)](#module_actions.profile.updateProfileDone) ⇒ Action + * [.addSkillInit()](#module_actions.profile.addSkillInit) ⇒ Action + * [.addSkillDone(handle, tokenV3, skill)](#module_actions.profile.addSkillDone) ⇒ Action + * [.hideSkillInit()](#module_actions.profile.hideSkillInit) ⇒ Action + * [.hideSkillDone(handle, tokenV3, skill)](#module_actions.profile.hideSkillDone) ⇒ Action + * [.addWebLinkInit()](#module_actions.profile.addWebLinkInit) ⇒ Action + * [.addWebLinkDone(handle, tokenV3, webLink)](#module_actions.profile.addWebLinkDone) ⇒ Action + * [.deleteWebLinkInit(key)](#module_actions.profile.deleteWebLinkInit) ⇒ Action + * [.deleteWebLinkDone(handle, tokenV3, webLink)](#module_actions.profile.deleteWebLinkDone) ⇒ Action + * [.linkExternalAccountInit()](#module_actions.profile.linkExternalAccountInit) ⇒ Action + * [.linkExternalAccountDone(profile, tokenV3, providerType, callbackUrl)](#module_actions.profile.linkExternalAccountDone) ⇒ Action + * [.unlinkExternalAccountInit(providerType)](#module_actions.profile.unlinkExternalAccountInit) ⇒ Action + * [.unlinkExternalAccountDone(profile, tokenV3, providerType)](#module_actions.profile.unlinkExternalAccountDone) ⇒ Action + * [.saveEmailPreferencesInit()](#module_actions.profile.saveEmailPreferencesInit) ⇒ Action + * [.saveEmailPreferencesDone(profile, tokenV3, preferences)](#module_actions.profile.saveEmailPreferencesDone) ⇒ Action + * [.updatePasswordInit()](#module_actions.profile.updatePasswordInit) ⇒ Action + * [.updatePasswordDone(profile, tokenV3, newPassword, oldPassword)](#module_actions.profile.updatePasswordDone) ⇒ Action @@ -167,3 +196,282 @@ Creates an action that loads member's stats. | --- | --- | --- | | handle | String | Member handle. | + + +### actions.profile.getActiveChallengesCountInit() ⇒ Action +Creates an action that signals beginning of getting count of user's active challenges. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.getActiveChallengesCountDone(handle, tokenV3) ⇒ Action +Creates an action that gets count of user's active challenges from the backend. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Optional. Topcoder auth token v3. Without token only public challenges will be counted. With the token provided, the action will also count private challenges related to this user. | + + + +### actions.profile.getLinkedAccountsInit() ⇒ Action +Creates an action that signals beginning of getting linked accounts. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.getLinkedAccountsDone(profile, tokenV3) ⇒ Action +Creates an action that gets linked accounts. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | + + + +### actions.profile.getCredentialInit() ⇒ Action +Creates an action that signals beginning of getting credential. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.getCredentialDone(profile, tokenV3) ⇒ Action +Creates an action that gets credential. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | + + + +### actions.profile.getEmailPreferencesInit() ⇒ Action +Creates an action that signals beginning of getting email preferences. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.getEmailPreferencesDone(profile, tokenV3) ⇒ Action +Creates an action that gets email preferences. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | + + + +### actions.profile.uploadPhotoInit() ⇒ Action +Creates an action that signals beginning of uploading user's photo. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.uploadPhotoDone(handle, tokenV3, file) ⇒ Action +Creates an action that uploads user's photo. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Topcoder auth token v3. | +| file | String | The photo file. | + + + +### actions.profile.deletePhotoInit() ⇒ Action +Creates an action that signals beginning of deleting user's photo. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.updateProfileInit() ⇒ Action +Creates an action that signals beginning of updating user's profile. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.updateProfileDone(profile, tokenV3) ⇒ Action +Creates an action that updates user's profile. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | String | Topcoder user profile. | +| tokenV3 | String | Topcoder auth token v3. | + + + +### actions.profile.addSkillInit() ⇒ Action +Creates an action that signals beginning of adding user's skill. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.addSkillDone(handle, tokenV3, skill) ⇒ Action +Creates an action that adds user's skill. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Topcoder auth token v3. | +| skill | Object | Skill to add. | + + + +### actions.profile.hideSkillInit() ⇒ Action +Creates an action that signals beginning of hiding user's skill. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.hideSkillDone(handle, tokenV3, skill) ⇒ Action +Creates an action that hides user's skill. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Topcoder auth token v3. | +| skill | Object | Skill to hide. | + + + +### actions.profile.addWebLinkInit() ⇒ Action +Creates an action that signals beginning of adding user's web link. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.addWebLinkDone(handle, tokenV3, webLink) ⇒ Action +Creates an action that adds user's web link. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Topcoder auth token v3. | +| webLink | String | Web link to add. | + + + +### actions.profile.deleteWebLinkInit(key) ⇒ Action +Creates an action that signals beginning of deleting user's web link. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| key | Object | Web link key to delete. | + + + +### actions.profile.deleteWebLinkDone(handle, tokenV3, webLink) ⇒ Action +Creates an action that deletes user's web link. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle. | +| tokenV3 | String | Topcoder auth token v3. | +| webLink | String | Web link to delete. | + + + +### actions.profile.linkExternalAccountInit() ⇒ Action +Creates an action that signals beginning of linking external account. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.linkExternalAccountDone(profile, tokenV3, providerType, callbackUrl) ⇒ Action +Creates an action that links external account. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member handle. | +| tokenV3 | String | Topcoder auth token v3. | +| providerType | String | The external account service provider | +| callbackUrl | String | Optional. The callback url | + + + +### actions.profile.unlinkExternalAccountInit(providerType) ⇒ Action +Creates an action that signals beginning of unlinking external account. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| providerType | Object | External account provider type to delete. | + + + +### actions.profile.unlinkExternalAccountDone(profile, tokenV3, providerType) ⇒ Action +Creates an action that unlinks external account. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | +| providerType | String | The external account service provider | + + + +### actions.profile.saveEmailPreferencesInit() ⇒ Action +Creates an action that signals beginning of saving email preferences. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.saveEmailPreferencesDone(profile, tokenV3, preferences) ⇒ Action +Creates an action that saves email preferences. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | +| preferences | Object | The email preferences | + + + +### actions.profile.updatePasswordInit() ⇒ Action +Creates an action that signals beginning of updating user password. + +**Kind**: static method of [actions.profile](#module_actions.profile) + + +### actions.profile.updatePasswordDone(profile, tokenV3, newPassword, oldPassword) ⇒ Action +Creates an action that updates user password. + +**Kind**: static method of [actions.profile](#module_actions.profile) + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | Topcoder member profile. | +| tokenV3 | String | Topcoder auth token v3. | +| newPassword | String | The new password | +| oldPassword | String | The old password | + diff --git a/docs/index.md b/docs/index.md index 70f1bb72..ec065107 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,10 @@ messaging.

Actions related to user groups.

+actions.lookup
+

Actions related to lookup data.

+
+
actions.member-tasks

Actions for management of member tasks and payments. Under the hood it is very similar to the challenge listing management, as these tasks are in @@ -94,6 +98,11 @@ not look really necessary at the moment, thus we do not provide an action to really cancel group loading.

+reducers.lookup
+

Reducer for actions.lookup actions.

+

State segment managed by this reducer has the following structure:

+
+
reduces.member-tasks

Member tasks reducer.

@@ -162,6 +171,11 @@ Also each group in the group map is timestamped to keep caching of the loaded data.

+services.lookup
+

This module provides a service to get lookup data from Topcoder +via API V3.

+
+
services.members

This module provides a service for searching for Topcoder members via API V3.

diff --git a/docs/reducers.lookup.md b/docs/reducers.lookup.md new file mode 100644 index 00000000..24c01523 --- /dev/null +++ b/docs/reducers.lookup.md @@ -0,0 +1,59 @@ + + +## reducers.lookup +Reducer for [actions.lookup](#module_actions.lookup) actions. + +State segment managed by this reducer has the following structure: + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| approvedSkills | Array | '' | approved skill tags. | + + +* [reducers.lookup](#module_reducers.lookup) + * _static_ + * [.default](#module_reducers.lookup.default) + * [.factory()](#module_reducers.lookup.factory) ⇒ Promise + * _inner_ + * [~onGetApprovedSkills(state, action)](#module_reducers.lookup..onGetApprovedSkills) ⇒ Object + * [~create(initialState)](#module_reducers.lookup..create) ⇒ function + + + +### reducers.lookup.default +Reducer with default initial state. + +**Kind**: static property of [reducers.lookup](#module_reducers.lookup) + + +### reducers.lookup.factory() ⇒ Promise +Factory which creates a new reducer. + +**Kind**: static method of [reducers.lookup](#module_reducers.lookup) +**Resolves**: Function(state, action): state New reducer. + + +### reducers.lookup~onGetApprovedSkills(state, action) ⇒ Object +Handles LOOKUP/GET_APPROVED_SKILLS action. + +**Kind**: inner method of [reducers.lookup](#module_reducers.lookup) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.lookup~create(initialState) ⇒ function +Creates a new Lookup reducer with the specified initial state. + +**Kind**: inner method of [reducers.lookup](#module_reducers.lookup) +**Returns**: function - Lookup reducer. + +| Param | Type | Description | +| --- | --- | --- | +| initialState | Object | Optional. Initial state. | + diff --git a/docs/reducers.profile.md b/docs/reducers.profile.md index 5492081a..c81c34d5 100644 --- a/docs/reducers.profile.md +++ b/docs/reducers.profile.md @@ -18,6 +18,21 @@ Reducer for Profile API data * [~onGetInfoDone(state, action)](#module_reducers.profile..onGetInfoDone) ⇒ Object * [~onGetSkillsDone(state, action)](#module_reducers.profile..onGetSkillsDone) ⇒ Object * [~onGetStatsDone(state, action)](#module_reducers.profile..onGetStatsDone) ⇒ Object + * [~onGetActiveChallengesCountDone(state, action)](#module_reducers.profile..onGetActiveChallengesCountDone) ⇒ Object + * [~onGetLinkedAccountsDone(state, action)](#module_reducers.profile..onGetLinkedAccountsDone) ⇒ Object + * [~onGetCredentialDone(state, action)](#module_reducers.profile..onGetCredentialDone) ⇒ Object + * [~onGetEmailPreferencesDone(state, action)](#module_reducers.profile..onGetEmailPreferencesDone) ⇒ Object + * [~onUploadPhotoDone(state, action)](#module_reducers.profile..onUploadPhotoDone) ⇒ Object + * [~onDeletePhotoDone(state, action)](#module_reducers.profile..onDeletePhotoDone) ⇒ Object + * [~onUpdateProfileDone(state, action)](#module_reducers.profile..onUpdateProfileDone) ⇒ Object + * [~onAddSkillDone(state, action)](#module_reducers.profile..onAddSkillDone) ⇒ Object + * [~onHideSkillDone(state, action)](#module_reducers.profile..onHideSkillDone) ⇒ Object + * [~onAddWebLinkDone(state, action)](#module_reducers.profile..onAddWebLinkDone) ⇒ Object + * [~onDeleteWebLinkDone(state, action)](#module_reducers.profile..onDeleteWebLinkDone) ⇒ Object + * [~onLinkExternalAccountDone(state, action)](#module_reducers.profile..onLinkExternalAccountDone) ⇒ Object + * [~onUnlinkExternalAccountDone(state, action)](#module_reducers.profile..onUnlinkExternalAccountDone) ⇒ Object + * [~onSaveEmailPreferencesDone(state, action)](#module_reducers.profile..onSaveEmailPreferencesDone) ⇒ Object + * [~onUpdatePasswordDone(state, action)](#module_reducers.profile..onUpdatePasswordDone) ⇒ Object * [~create(initialState)](#module_reducers.profile..create) ⇒ function @@ -107,6 +122,201 @@ Handles PROFILE/GET_STATS_DONE action. | state | Object | | | action | Object | Payload will be JSON from api call | + + +### reducers.profile~onGetActiveChallengesCountDone(state, action) ⇒ Object +Handles PROFILE/GET_ACTIVE_CHALLENGES_COUNT_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onGetLinkedAccountsDone(state, action) ⇒ Object +Handles PROFILE/GET_LINKED_ACCOUNTS_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onGetCredentialDone(state, action) ⇒ Object +Handles PROFILE/GET_CREDENTIAL_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onGetEmailPreferencesDone(state, action) ⇒ Object +Handles PROFILE/GET_EMAIL_PREFERENCES_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onUploadPhotoDone(state, action) ⇒ Object +Handles PROFILE/UPLOAD_PHOTO_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onDeletePhotoDone(state, action) ⇒ Object +Handles PROFILE/DELETE_PHOTO_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onUpdateProfileDone(state, action) ⇒ Object +Handles PROFILE/UPDATE_PROFILE_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onAddSkillDone(state, action) ⇒ Object +Handles PROFILE/ADD_SKILL_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onHideSkillDone(state, action) ⇒ Object +Handles PROFILE/HIDE_SKILL_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onAddWebLinkDone(state, action) ⇒ Object +Handles PROFILE/ADD_WEB_LINK_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onDeleteWebLinkDone(state, action) ⇒ Object +Handles PROFILE/DELETE_WEB_LINK_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onLinkExternalAccountDone(state, action) ⇒ Object +Handles PROFILE/LINK_EXTERNAL_ACCOUNT_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onUnlinkExternalAccountDone(state, action) ⇒ Object +Handles PROFILE/UNLINK_EXTERNAL_ACCOUNT_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onSaveEmailPreferencesDone(state, action) ⇒ Object +Handles PROFILE/SAVE_EMAIL_PREFERENCES_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + + + +### reducers.profile~onUpdatePasswordDone(state, action) ⇒ Object +Handles PROFILE/UPDATE_PASSWORD_DONE action. + +**Kind**: inner method of [reducers.profile](#module_reducers.profile) +**Returns**: Object - New state + +| Param | Type | Description | +| --- | --- | --- | +| state | Object | | +| action | Object | Payload will be JSON from api call | + ### reducers.profile~create(initialState) ⇒ function diff --git a/docs/services.api.md b/docs/services.api.md index 015b5986..eb787a8e 100644 --- a/docs/services.api.md +++ b/docs/services.api.md @@ -19,6 +19,8 @@ This module provides a service for conventient access to Topcoder APIs. * [.postJson(endpoint, json)](#module_services.api..Api+postJson) ⇒ Promise * [.put(endpoint, body)](#module_services.api..Api+put) ⇒ Promise * [.putJson(endpoint, json)](#module_services.api..Api+putJson) ⇒ Promise + * [.patch(endpoint, body)](#module_services.api..Api+patch) ⇒ Promise + * [.patchJson(endpoint, json)](#module_services.api..Api+patchJson) ⇒ Promise * [.upload(endpoint, body, callback)](#module_services.api..Api+upload) ⇒ Promise @@ -70,6 +72,8 @@ thing we need to be different is the base URL and auth token to use. * [.postJson(endpoint, json)](#module_services.api..Api+postJson) ⇒ Promise * [.put(endpoint, body)](#module_services.api..Api+put) ⇒ Promise * [.putJson(endpoint, json)](#module_services.api..Api+putJson) ⇒ Promise + * [.patch(endpoint, body)](#module_services.api..Api+patch) ⇒ Promise + * [.patchJson(endpoint, json)](#module_services.api..Api+patchJson) ⇒ Promise * [.upload(endpoint, body, callback)](#module_services.api..Api+upload) ⇒ Promise @@ -182,6 +186,30 @@ Sends PUT request to the specified endpoint. | endpoint | String | | json | JSON | + + +#### api.patch(endpoint, body) ⇒ Promise +Sends PATCH request to the specified endpoint. + +**Kind**: instance method of [Api](#module_services.api..Api) + +| Param | Type | +| --- | --- | +| endpoint | String | +| body | Blob \| BufferSource \| FormData \| String | + + + +#### api.patchJson(endpoint, json) ⇒ Promise +Sends PATCH request to the specified endpoint. + +**Kind**: instance method of [Api](#module_services.api..Api) + +| Param | Type | +| --- | --- | +| endpoint | String | +| json | JSON | + #### api.upload(endpoint, body, callback) ⇒ Promise diff --git a/docs/services.lookup.md b/docs/services.lookup.md new file mode 100644 index 00000000..a0088232 --- /dev/null +++ b/docs/services.lookup.md @@ -0,0 +1,56 @@ + + +## services.lookup +This module provides a service to get lookup data from Topcoder +via API V3. + + +* [services.lookup](#module_services.lookup) + * _static_ + * [.getService(tokenV3)](#module_services.lookup.getService) ⇒ LookupService + * _inner_ + * [~LookupService](#module_services.lookup..LookupService) + * [new LookupService(tokenV3)](#new_module_services.lookup..LookupService_new) + * [.getTags(params)](#module_services.lookup..LookupService+getTags) ⇒ Promise + + + +### services.lookup.getService(tokenV3) ⇒ LookupService +Returns a new or existing lookup service. + +**Kind**: static method of [services.lookup](#module_services.lookup) +**Returns**: LookupService - Lookup service object + +| Param | Type | Description | +| --- | --- | --- | +| tokenV3 | String | Optional. Auth token for Topcoder API v3. | + + + +### services.lookup~LookupService +**Kind**: inner class of [services.lookup](#module_services.lookup) + +* [~LookupService](#module_services.lookup..LookupService) + * [new LookupService(tokenV3)](#new_module_services.lookup..LookupService_new) + * [.getTags(params)](#module_services.lookup..LookupService+getTags) ⇒ Promise + + + +#### new LookupService(tokenV3) + +| Param | Type | Description | +| --- | --- | --- | +| tokenV3 | String | Optional. Auth token for Topcoder API v3. | + + + +#### lookupService.getTags(params) ⇒ Promise +Gets tags. + +**Kind**: instance method of [LookupService](#module_services.lookup..LookupService) +**Returns**: Promise - Resolves to the tags. + +| Param | Type | Description | +| --- | --- | --- | +| params | Object | Parameters | + diff --git a/docs/services.members.md b/docs/services.members.md index 2af2f272..87dade98 100644 --- a/docs/services.members.md +++ b/docs/services.members.md @@ -18,6 +18,14 @@ members via API V3. * [.getSkills(handle)](#module_services.members..MembersService+getSkills) ⇒ Promise * [.getStats(handle)](#module_services.members..MembersService+getStats) ⇒ Promise * [.getMemberSuggestions(keyword)](#module_services.members..MembersService+getMemberSuggestions) ⇒ Promise + * [.addWebLink(userHandle, webLink)](#module_services.members..MembersService+addWebLink) ⇒ Promise + * [.deleteWebLink(userHandle, webLinkHandle)](#module_services.members..MembersService+deleteWebLink) ⇒ Promise + * [.addSkill(handle, skillTagId)](#module_services.members..MembersService+addSkill) ⇒ Promise + * [.hideSkill(handle, skillTagId)](#module_services.members..MembersService+hideSkill) ⇒ Promise + * [.updateMemberProfile(profile)](#module_services.members..MembersService+updateMemberProfile) ⇒ Promise + * [.getPresignedUrl(userHandle, file)](#module_services.members..MembersService+getPresignedUrl) ⇒ Promise + * [.updateMemberPhoto(S3Response)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.uploadFileToS3(presignedUrlResponse)](#module_services.members..MembersService+uploadFileToS3) ⇒ Promise @@ -47,6 +55,14 @@ Service class. * [.getSkills(handle)](#module_services.members..MembersService+getSkills) ⇒ Promise * [.getStats(handle)](#module_services.members..MembersService+getStats) ⇒ Promise * [.getMemberSuggestions(keyword)](#module_services.members..MembersService+getMemberSuggestions) ⇒ Promise + * [.addWebLink(userHandle, webLink)](#module_services.members..MembersService+addWebLink) ⇒ Promise + * [.deleteWebLink(userHandle, webLinkHandle)](#module_services.members..MembersService+deleteWebLink) ⇒ Promise + * [.addSkill(handle, skillTagId)](#module_services.members..MembersService+addSkill) ⇒ Promise + * [.hideSkill(handle, skillTagId)](#module_services.members..MembersService+hideSkill) ⇒ Promise + * [.updateMemberProfile(profile)](#module_services.members..MembersService+updateMemberProfile) ⇒ Promise + * [.getPresignedUrl(userHandle, file)](#module_services.members..MembersService+getPresignedUrl) ⇒ Promise + * [.updateMemberPhoto(S3Response)](#module_services.members..MembersService+updateMemberPhoto) ⇒ Promise + * [.uploadFileToS3(presignedUrlResponse)](#module_services.members..MembersService+uploadFileToS3) ⇒ Promise @@ -144,3 +160,104 @@ WARNING: This method requires v3 authorization. | --- | --- | --- | | keyword | String | Partial string to find suggestions for | + + +#### membersService.addWebLink(userHandle, webLink) ⇒ Promise +Adds external web link for member. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| userHandle | String | The user handle | +| webLink | String | The external web link | + + + +#### membersService.deleteWebLink(userHandle, webLinkHandle) ⇒ Promise +Deletes external web link for member. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| userHandle | String | The user handle | +| webLinkHandle | String | The external web link handle | + + + +#### membersService.addSkill(handle, skillTagId) ⇒ Promise +Adds user skill. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to operation result + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle | +| skillTagId | Number | Skill tag id | + + + +#### membersService.hideSkill(handle, skillTagId) ⇒ Promise +Hides user skill. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to operation result + +| Param | Type | Description | +| --- | --- | --- | +| handle | String | Topcoder user handle | +| skillTagId | Number | Skill tag id | + + + +#### membersService.updateMemberProfile(profile) ⇒ Promise +Updates member profile. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | The profile to update. | + + + +#### membersService.getPresignedUrl(userHandle, file) ⇒ Promise +Gets presigned url for member photo file. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| userHandle | String | The user handle | +| file | File | The file to get its presigned url | + + + +#### membersService.updateMemberPhoto(S3Response) ⇒ Promise +Updates member photo. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| S3Response | Object | The response from uploadFileToS3() function. | + + + +#### membersService.uploadFileToS3(presignedUrlResponse) ⇒ Promise +Uploads file to S3. + +**Kind**: instance method of [MembersService](#module_services.members..MembersService) +**Returns**: Promise - Resolves to the api response content + +| Param | Type | Description | +| --- | --- | --- | +| presignedUrlResponse | Object | The presigned url response from getPresignedUrl() function. | + diff --git a/docs/services.user.md b/docs/services.user.md index 0daaf23a..de282529 100644 --- a/docs/services.user.md +++ b/docs/services.user.md @@ -15,6 +15,14 @@ The User service provides functionality related to Topcoder user * [.getAchievements(username)](#module_services.user..User+getAchievements) ⇒ Object * [.getUserPublic(username)](#module_services.user..User+getUserPublic) ⇒ Object * [.getUser(username)](#module_services.user..User+getUser) ⇒ Promise + * [.getEmailPreferences(userId)](#module_services.user..User+getEmailPreferences) ⇒ Promise + * [.saveEmailPreferences(user, preferences)](#module_services.user..User+saveEmailPreferences) ⇒ Promise + * [.getCredential(userId)](#module_services.user..User+getCredential) ⇒ Promise + * [.updatePassword(userId, newPassword, oldPassword)](#module_services.user..User+updatePassword) ⇒ Promise + * [.getLinkedAccounts(userId)](#module_services.user..User+getLinkedAccounts) ⇒ Promise + * [.unlinkExternalAccount(userId, provider)](#module_services.user..User+unlinkExternalAccount) ⇒ Promise + * [.linkExternalAccount(userId, provider, callbackUrl)](#module_services.user..User+linkExternalAccount) ⇒ Promise + * [~getSocialUserData(profile, accessToken)](#module_services.user..getSocialUserData) ⇒ Object @@ -47,6 +55,13 @@ Service class. * [.getAchievements(username)](#module_services.user..User+getAchievements) ⇒ Object * [.getUserPublic(username)](#module_services.user..User+getUserPublic) ⇒ Object * [.getUser(username)](#module_services.user..User+getUser) ⇒ Promise + * [.getEmailPreferences(userId)](#module_services.user..User+getEmailPreferences) ⇒ Promise + * [.saveEmailPreferences(user, preferences)](#module_services.user..User+saveEmailPreferences) ⇒ Promise + * [.getCredential(userId)](#module_services.user..User+getCredential) ⇒ Promise + * [.updatePassword(userId, newPassword, oldPassword)](#module_services.user..User+updatePassword) ⇒ Promise + * [.getLinkedAccounts(userId)](#module_services.user..User+getLinkedAccounts) ⇒ Promise + * [.unlinkExternalAccount(userId, provider)](#module_services.user..User+unlinkExternalAccount) ⇒ Promise + * [.linkExternalAccount(userId, provider, callbackUrl)](#module_services.user..User+linkExternalAccount) ⇒ Promise @@ -95,3 +110,116 @@ NOTE: Only admins are authorized to use the underlying endpoint. | --- | --- | | username | String | + + +#### user.getEmailPreferences(userId) ⇒ Promise +Gets email preferences. + +NOTE: Only admins are authorized to use the underlying endpoint. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the email preferences result + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The TopCoder user id | + + + +#### user.saveEmailPreferences(user, preferences) ⇒ Promise +Saves email preferences. + +NOTE: Only admins are authorized to use the underlying endpoint. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the email preferences result + +| Param | Type | Description | +| --- | --- | --- | +| user | Object | The TopCoder user | +| preferences | Object | The email preferences | + + + +#### user.getCredential(userId) ⇒ Promise +Gets credential for the specified user id. + +NOTE: Only admins are authorized to use the underlying endpoint. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the linked accounts array. + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The user id | + + + +#### user.updatePassword(userId, newPassword, oldPassword) ⇒ Promise +Updates user password. + +NOTE: Only admins are authorized to use the underlying endpoint. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the update result. + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The user id | +| newPassword | String | The new password | +| oldPassword | String | The old password | + + + +#### user.getLinkedAccounts(userId) ⇒ Promise +Gets linked accounts for the specified user id. + +NOTE: Only admins are authorized to use the underlying endpoint. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the linked accounts array. + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The user id | + + + +#### user.unlinkExternalAccount(userId, provider) ⇒ Promise +Unlinks external account. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the unlink result + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The TopCoder user id | +| provider | String | The external account service provider | + + + +#### user.linkExternalAccount(userId, provider, callbackUrl) ⇒ Promise +Links external account. + +**Kind**: instance method of [User](#module_services.user..User) +**Returns**: Promise - Resolves to the linked account result + +| Param | Type | Description | +| --- | --- | --- | +| userId | Number | The TopCoder user id | +| provider | String | The external account service provider | +| callbackUrl | String | Optional. The callback url | + + + +### services.user~getSocialUserData(profile, accessToken) ⇒ Object +Gets social user data. + +**Kind**: inner method of [services.user](#module_services.user) +**Returns**: Object - Social user data + +| Param | Type | Description | +| --- | --- | --- | +| profile | Object | The user social profile | +| accessToken | \* | The access token | + diff --git a/package-lock.json b/package-lock.json index ebabdc18..2792f6b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,6 +160,11 @@ } } }, + "Base64": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/Base64/-/Base64-0.1.4.tgz", + "integrity": "sha1-6fbGvvVn/WNepBYqsU3TKedKpt4=" + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -543,6 +548,39 @@ "integrity": "sha1-ri1acpR38onWDdf5amMUoi3Wwio=", "dev": true }, + "auth0-js": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-6.8.4.tgz", + "integrity": "sha1-Qw3Uystk2NFdabHmIRhPmipkCmE=", + "requires": { + "Base64": "0.1.4", + "json-fallback": "0.0.1", + "jsonp": "0.0.4", + "qs": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8", + "reqwest": "1.1.6", + "trim": "0.0.1", + "winchan": "0.1.4", + "xtend": "2.1.2" + }, + "dependencies": { + "object-keys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", + "integrity": "sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=" + }, + "qs": { + "version": "git+https://github.com/jfromaniello/node-querystring.git#5d96513991635e3e22d7aa54a8584d6ce97cace8" + }, + "xtend": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", + "integrity": "sha1-bv7MKk2tjmlixJAbM3znuoe10os=", + "requires": { + "object-keys": "0.4.0" + } + } + } + }, "autoprefixer": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-8.4.1.tgz", @@ -7238,6 +7276,11 @@ "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", "dev": true }, + "json-fallback": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-fallback/-/json-fallback-0.0.1.tgz", + "integrity": "sha1-6OMIPD/drQ+bXwnTMSB0RCWA14E=" + }, "json-loader": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/json-loader/-/json-loader-0.5.7.tgz", @@ -7287,6 +7330,14 @@ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true }, + "jsonp": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.0.4.tgz", + "integrity": "sha1-lGZaS3caq+y4qshBNbmVlHVpGL0=", + "requires": { + "debug": "2.6.9" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -9655,6 +9706,11 @@ } } }, + "reqwest": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/reqwest/-/reqwest-1.1.6.tgz", + "integrity": "sha1-S2iU0pWWv46CSiXzSXXfFVYu6BM=" + }, "reselect": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", @@ -18937,6 +18993,11 @@ "punycode": "2.1.0" } }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" + }, "trim-right": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", @@ -20014,6 +20075,11 @@ "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", "dev": true }, + "winchan": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.1.4.tgz", + "integrity": "sha1-iPoSQRzVQutiYBjDihlry7F5k7s=" + }, "window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", diff --git a/package.json b/package.json index e3569660..7ccbb3ae 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "version": "0.0.6", "dependencies": { + "auth0-js": "^6.8.4", "isomorphic-fetch": "^2.2.1", "le_node": "^1.7.0", "lodash": "^4.17.5", diff --git a/src/actions/index.js b/src/actions/index.js index dff22096..333494c1 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -10,6 +10,7 @@ import profileActions from './profile'; import memberActions from './members'; import memberTaskActions from './member-tasks'; import reviewOpportunityActions from './reviewOpportunity'; +import lookupActions from './lookup'; export const actions = { auth: authActions.auth, @@ -24,6 +25,7 @@ export const actions = { members: memberActions.members, memberTasks: memberTaskActions.memberTasks, reviewOpportunity: reviewOpportunityActions.reviewOpportunity, + lookup: lookupActions.lookup, }; export default undefined; diff --git a/src/actions/lookup.js b/src/actions/lookup.js new file mode 100644 index 00000000..4b80311e --- /dev/null +++ b/src/actions/lookup.js @@ -0,0 +1,27 @@ +/** + * @module "actions.lookup" + * @desc Actions related to lookup data. + */ + +import { createActions } from 'redux-actions'; +import { getService } from '../services/lookup'; + +/** + * @static + * @desc Gets approved skill tags. + * @return {Action} + */ +function getApprovedSkills() { + const service = getService(); + const params = { + domain: 'SKILLS', + status: 'APPROVED', + }; + return service.getTags(params); +} + +export default createActions({ + LOOKUP: { + GET_APPROVED_SKILLS: getApprovedSkills, + }, +}); diff --git a/src/actions/profile.js b/src/actions/profile.js index 9a5d8b65..69540709 100644 --- a/src/actions/profile.js +++ b/src/actions/profile.js @@ -8,6 +8,7 @@ import { createActions } from 'redux-actions'; import { getService as getUserService } from '../services/user'; import { getService as getMembersService } from '../services/members'; +import { getService as getChallengesService } from '../services/challenges'; /** * @static @@ -127,6 +128,319 @@ function getStatsDone(handle) { return getMembersService().getStats(handle); } +/** + * @static + * @desc Creates an action that signals beginning of getting count of user's active challenges. + * @return {Action} + */ +function getActiveChallengesCountInit() {} + +/** + * @static + * @desc Creates an action that gets count of user's active challenges from the backend. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Optional. Topcoder auth token v3. Without token only + * public challenges will be counted. With the token provided, the action will + * also count private challenges related to this user. + * @return {Action} + */ +function getActiveChallengesCountDone(handle, tokenV3) { + const service = getChallengesService(tokenV3); + const filter = { status: 'ACTIVE' }; + const params = { limit: 1, offset: 0 }; + + const calls = []; + calls.push(service.getUserChallenges(handle, filter, params)); + calls.push(service.getUserMarathonMatches(handle, filter, params)); + + return Promise.all(calls).then(([uch, umm]) => uch.totalCount + umm.totalCount); +} + +/** + * @static + * @desc Creates an action that signals beginning of getting linked accounts. + * @return {Action} + */ +function getLinkedAccountsInit() {} + +/** + * @static + * @desc Creates an action that gets linked accounts. + * + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @return {Action} + */ +function getLinkedAccountsDone(profile, tokenV3) { + const service = getUserService(tokenV3); + return service.getLinkedAccounts(profile.userId); +} + +/** + * @static + * @desc Creates an action that signals beginning of getting credential. + * @return {Action} + */ +function getCredentialInit() {} + +/** + * @static + * @desc Creates an action that gets credential. + * + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @return {Action} + */ +function getCredentialDone(profile, tokenV3) { + const service = getUserService(tokenV3); + return service.getCredential(profile.userId); +} + +/** + * @static + * @desc Creates an action that signals beginning of getting email preferences. + * @return {Action} + */ +function getEmailPreferencesInit() {} + +/** + * @static + * @desc Creates an action that gets email preferences. + * + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @return {Action} + */ +function getEmailPreferencesDone(profile, tokenV3) { + const service = getUserService(tokenV3); + return service.getEmailPreferences(profile.userId); +} + +/** + * @static + * @desc Creates an action that signals beginning of uploading user's photo. + * @return {Action} + */ +function uploadPhotoInit() {} + +/** + * @static + * @desc Creates an action that uploads user's photo. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} file The photo file. + * @return {Action} + */ +function uploadPhotoDone(handle, tokenV3, file) { + const service = getMembersService(tokenV3); + return service.getPresignedUrl(handle, file) + .then(res => service.uploadFileToS3(res)) + .then(res => service.updateMemberPhoto(res)) + .then(photoURL => ({ handle, photoURL })); +} + +/** + * @static + * @desc Creates an action that signals beginning of deleting user's photo. + * @return {Action} + */ +function deletePhotoInit() {} + +/** + * @static + * @desc Creates an action that signals beginning of updating user's profile. + * @return {Action} + */ +function updateProfileInit() {} + +/** + * @static + * @desc Creates an action that updates user's profile. + * @param {String} profile Topcoder user profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @return {Action} + */ +function updateProfileDone(profile, tokenV3) { + const service = getMembersService(tokenV3); + return service.updateMemberProfile(profile); +} + +/** + * @static + * @desc Creates an action that signals beginning of adding user's skill. + * @return {Action} + */ +function addSkillInit() {} + +/** + * @static + * @desc Creates an action that adds user's skill. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {Object} skill Skill to add. + * @return {Action} + */ +function addSkillDone(handle, tokenV3, skill) { + const service = getMembersService(tokenV3); + return service.addSkill(handle, skill.tagId) + .then(res => ({ skills: res.skills, handle, skill })); +} + +/** + * @static + * @desc Creates an action that signals beginning of hiding user's skill. + * @return {Action} + */ +function hideSkillInit() {} + +/** + * @static + * @desc Creates an action that hides user's skill. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {Object} skill Skill to hide. + * @return {Action} + */ +function hideSkillDone(handle, tokenV3, skill) { + const service = getMembersService(tokenV3); + return service.hideSkill(handle, skill.tagId) + .then(res => ({ skills: res.skills, handle, skill })); +} + +/** + * @static + * @desc Creates an action that signals beginning of adding user's web link. + * @return {Action} + */ +function addWebLinkInit() {} + +/** + * @static + * @desc Creates an action that adds user's web link. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} webLink Web link to add. + * @return {Action} + */ +function addWebLinkDone(handle, tokenV3, webLink) { + const service = getMembersService(tokenV3); + return service.addWebLink(handle, webLink).then(res => ({ data: res, handle })); +} + +/** + * @static + * @desc Creates an action that signals beginning of deleting user's web link. + * @param {Object} key Web link key to delete. + * @return {Action} + */ +function deleteWebLinkInit({ key }) { + return { key }; +} + +/** + * @static + * @desc Creates an action that deletes user's web link. + * @param {String} handle Topcoder user handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} webLink Web link to delete. + * @return {Action} + */ +function deleteWebLinkDone(handle, tokenV3, webLink) { + const service = getMembersService(tokenV3); + return service.deleteWebLink(handle, webLink.key).then(res => ({ data: res, handle })); +} + +/** + * @static + * @desc Creates an action that signals beginning of linking external account. + * @return {Action} + */ +function linkExternalAccountInit() {} + +/** + * @static + * @desc Creates an action that links external account. + * @param {Object} profile Topcoder member handle. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} providerType The external account service provider + * @param {String} callbackUrl Optional. The callback url + * @return {Action} + */ +function linkExternalAccountDone(profile, tokenV3, providerType, callbackUrl) { + const service = getUserService(tokenV3); + return service.linkExternalAccount(profile.userId, providerType, callbackUrl) + .then(res => ({ data: res, handle: profile.handle })); +} + +/** + * @static + * @desc Creates an action that signals beginning of unlinking external account. + * @param {Object} providerType External account provider type to delete. + * @return {Action} + */ +function unlinkExternalAccountInit({ providerType }) { + return { providerType }; +} + +/** + * @static + * @desc Creates an action that unlinks external account. + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} providerType The external account service provider + * @return {Action} + */ +function unlinkExternalAccountDone(profile, tokenV3, providerType) { + const service = getUserService(tokenV3); + return service.unlinkExternalAccount(profile.userId, providerType) + .then(() => ({ providerType, handle: profile.handle })); +} + +/** + * @static + * @desc Creates an action that signals beginning of saving email preferences. + * @return {Action} + */ +function saveEmailPreferencesInit() {} + +/** + * @static + * @desc Creates an action that saves email preferences. + * + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {Object} preferences The email preferences + * @return {Action} + */ +function saveEmailPreferencesDone(profile, tokenV3, preferences) { + const service = getUserService(tokenV3); + return service.saveEmailPreferences(profile, preferences) + .then(res => ({ data: res, handle: profile.handle })); +} + +/** + * @static + * @desc Creates an action that signals beginning of updating user password. + * @return {Action} + */ +function updatePasswordInit() {} + +/** + * @static + * @desc Creates an action that updates user password. + * + * @param {Object} profile Topcoder member profile. + * @param {String} tokenV3 Topcoder auth token v3. + * @param {String} newPassword The new password + * @param {String} oldPassword The old password + * @return {Action} + */ +function updatePasswordDone(profile, tokenV3, newPassword, oldPassword) { + const service = getUserService(tokenV3); + return service.updatePassword(profile.userId, newPassword, oldPassword) + .then(res => ({ data: res, handle: profile.handle })); +} + export default createActions({ PROFILE: { LOAD_PROFILE: loadProfile, @@ -142,5 +456,35 @@ export default createActions({ GET_SKILLS_DONE: getSkillsDone, GET_STATS_INIT: getStatsInit, GET_STATS_DONE: getStatsDone, + GET_ACTIVE_CHALLENGES_COUNT_INIT: getActiveChallengesCountInit, + GET_ACTIVE_CHALLENGES_COUNT_DONE: getActiveChallengesCountDone, + GET_LINKED_ACCOUNTS_INIT: getLinkedAccountsInit, + GET_LINKED_ACCOUNTS_DONE: getLinkedAccountsDone, + GET_EMAIL_PREFERENCES_INIT: getEmailPreferencesInit, + GET_EMAIL_PREFERENCES_DONE: getEmailPreferencesDone, + GET_CREDENTIAL_INIT: getCredentialInit, + GET_CREDENTIAL_DONE: getCredentialDone, + UPLOAD_PHOTO_INIT: uploadPhotoInit, + UPLOAD_PHOTO_DONE: uploadPhotoDone, + DELETE_PHOTO_INIT: deletePhotoInit, + DELETE_PHOTO_DONE: updateProfileDone, + UPDATE_PROFILE_INIT: updateProfileInit, + UPDATE_PROFILE_DONE: updateProfileDone, + ADD_SKILL_INIT: addSkillInit, + ADD_SKILL_DONE: addSkillDone, + HIDE_SKILL_INIT: hideSkillInit, + HIDE_SKILL_DONE: hideSkillDone, + ADD_WEB_LINK_INIT: addWebLinkInit, + ADD_WEB_LINK_DONE: addWebLinkDone, + DELETE_WEB_LINK_INIT: deleteWebLinkInit, + DELETE_WEB_LINK_DONE: deleteWebLinkDone, + LINK_EXTERNAL_ACCOUNT_INIT: linkExternalAccountInit, + LINK_EXTERNAL_ACCOUNT_DONE: linkExternalAccountDone, + UNLINK_EXTERNAL_ACCOUNT_INIT: unlinkExternalAccountInit, + UNLINK_EXTERNAL_ACCOUNT_DONE: unlinkExternalAccountDone, + SAVE_EMAIL_PREFERENCES_INIT: saveEmailPreferencesInit, + SAVE_EMAIL_PREFERENCES_DONE: saveEmailPreferencesDone, + UPDATE_PASSWORD_INIT: updatePasswordInit, + UPDATE_PASSWORD_DONE: updatePasswordDone, }, }); diff --git a/src/index.js b/src/index.js index f892cdd8..ac7ff9e0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ /** * Export the lib. */ -import reducers, { factory as reducerFactory } from './reducers'; +import reducers, { factories as reducerFactories, factory as reducerFactory } from './reducers'; -export { reducerFactory }; +export { reducerFactories, reducerFactory }; export { reducers }; diff --git a/src/reducers/auth.js b/src/reducers/auth.js index 5afb468b..6957133e 100644 --- a/src/reducers/auth.js +++ b/src/reducers/auth.js @@ -16,6 +16,7 @@ import _ from 'lodash'; import { decodeToken } from 'tc-accounts'; import { redux } from 'topcoder-react-utils'; import actions from '../actions/auth'; +import profileActions from '../actions/profile'; /** * Handles actions.auth.loadProfile action. @@ -55,6 +56,51 @@ function create(initialState) { groups: state.profile.groups.concat({ id: payload.groupId.toString() }), }, }), + [profileActions.profile.uploadPhotoDone]: (state, { payload, error }) => { + if (error) { + return state; + } + if (!state.profile || state.profile.handle !== payload.handle) { + return state; + } + return { + ...state, + profile: { + ...state.profile, + photoURL: payload.photoURL, + }, + }; + }, + [profileActions.profile.deletePhotoDone]: (state, { payload, error }) => { + if (error) { + return state; + } + if (!state.profile || state.profile.handle !== payload.handle) { + return state; + } + return { + ...state, + profile: { + ...state.profile, + photoURL: null, + }, + }; + }, + [profileActions.profile.updateProfileDone]: (state, { payload, error }) => { + if (error) { + return state; + } + if (!state.profile || state.profile.handle !== payload.handle) { + return state; + } + return { + ...state, + profile: { + ...state.profile, + ...payload, + }, + }; + }, }, _.defaults(initialState, { authenticating: true, profile: null, diff --git a/src/reducers/index.js b/src/reducers/index.js index ed0d4829..708b46c5 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -12,6 +12,7 @@ import errors, { factory as errorsFactory } from './errors'; import challenge, { factory as challengeFactory } from './challenge'; import profile, { factory as profileFactory } from './profile'; import members, { factory as membersFactory } from './members'; +import lookup, { factory as lookupFactory } from './lookup'; import memberTasks, { factory as memberTasksFactory } from './member-tasks'; import reviewOpportunity, { factory as reviewOpportunityFactory } from './reviewOpportunity'; @@ -29,6 +30,7 @@ export function factory(options) { errors: errorsFactory(options), challenge: challengeFactory(options), profile: profileFactory(options), + lookup: lookupFactory(options), members: membersFactory(options), memberTasks: memberTasksFactory(options), reviewOpportunity: reviewOpportunityFactory(options), @@ -36,6 +38,22 @@ export function factory(options) { }); } +export const factories = { + authFactory, + statsFactory, + termsFactory, + directFactory, + groupsFactory, + errorsFactory, + challengeFactory, + profileFactory, + lookupFactory, + membersFactory, + memberTasksFactory, + reviewOpportunityFactory, + mySubmissionsManagementFactory, +}; + export default ({ auth, stats, @@ -45,6 +63,7 @@ export default ({ errors, challenge, profile, + lookup, members, memberTasks, reviewOpportunity, diff --git a/src/reducers/lookup.js b/src/reducers/lookup.js new file mode 100644 index 00000000..bf217a85 --- /dev/null +++ b/src/reducers/lookup.js @@ -0,0 +1,60 @@ +/** + * @module "reducers.lookup" + * @desc Reducer for {@link module:actions.lookup} actions. + * + * State segment managed by this reducer has the following structure: + * @param {Array} approvedSkills='' approved skill tags. + */ +import _ from 'lodash'; +import { handleActions } from 'redux-actions'; +import logger from '../utils/logger'; +import actions from '../actions/lookup'; + +/** + * Handles LOOKUP/GET_APPROVED_SKILLS action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetApprovedSkills(state, { payload, error }) { + if (error) { + logger.error('Failed to get approved skill tags', payload); + return { ...state, loadingApprovedSkillsError: true }; + } + + return ({ + ...state, + loadingApprovedSkillsError: false, + approvedSkills: payload, + }); +} + +/** + * Creates a new Lookup reducer with the specified initial state. + * @param {Object} initialState Optional. Initial state. + * @return {Function} Lookup reducer. + */ +function create(initialState = {}) { + const a = actions.lookup; + return handleActions({ + [a.getApprovedSkills]: onGetApprovedSkills, + }, _.defaults(initialState, { + approvedSkills: [], + })); +} + +/** + * Factory which creates a new reducer. + * @return {Promise} + * @resolves {Function(state, action): state} New reducer. + */ +export function factory() { + return Promise.resolve(create()); +} + +/** + * @static + * @member default + * @desc Reducer with default initial state. + */ +export default create(); diff --git a/src/reducers/profile.js b/src/reducers/profile.js index 51970fa3..2f773db4 100644 --- a/src/reducers/profile.js +++ b/src/reducers/profile.js @@ -6,6 +6,8 @@ import _ from 'lodash'; import { handleActions } from 'redux-actions'; import actions from '../actions/profile'; +import logger from '../utils/logger'; +import { fireErrorMessage } from '../utils/errors'; /** * Handles PROFILE/GET_ACHIEVEMENTS_DONE action. @@ -103,6 +105,339 @@ function onGetStatsDone(state, { payload, error }) { return ({ ...state, stats: payload, loadingError: false }); } +/** + * Handles PROFILE/GET_ACTIVE_CHALLENGES_COUNT_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetActiveChallengesCountDone(state, { payload, error }) { + if (error) { + return { ...state, loadingError: true }; + } + + return ({ ...state, activeChallengesCount: payload, loadingError: false }); +} + +/** + * Handles PROFILE/GET_LINKED_ACCOUNTS_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetLinkedAccountsDone(state, { payload, error }) { + if (error) { + return { ...state, loadingError: true }; + } + + return { ...state, linkedAccounts: payload.profiles, loadingError: false }; +} + +/** + * Handles PROFILE/GET_CREDENTIAL_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetCredentialDone(state, { payload, error }) { + if (error) { + return { ...state, loadingError: true }; + } + + return { ...state, credential: payload.credential, loadingError: false }; +} + +/** + * Handles PROFILE/GET_EMAIL_PREFERENCES_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetEmailPreferencesDone(state, { payload, error }) { + if (error) { + return { ...state, loadingError: true }; + } + + return { ...state, emailPreferences: payload.subscriptions, loadingError: false }; +} + +/** + * Handles PROFILE/UPLOAD_PHOTO_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onUploadPhotoDone(state, { payload, error }) { + const newState = { ...state, uploadingPhoto: false }; + + if (error) { + logger.error('Failed to upload user photo', payload); + fireErrorMessage('ERROR: Failed to upload photo!'); + return newState; + } + + if (!newState.info || newState.info.handle !== payload.handle) { + return newState; + } + + return { + ...newState, + info: { + ...newState.info, + photoURL: payload.photoURL, + }, + }; +} + +/** + * Handles PROFILE/DELETE_PHOTO_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onDeletePhotoDone(state, { payload, error }) { + const newState = { ...state, deletingPhoto: false }; + + if (error) { + logger.error('Failed to delete user photo', payload); + fireErrorMessage('ERROR: Failed to delete photo!'); + return newState; + } + + if (!newState.info || newState.info.handle !== payload.handle) { + return newState; + } + + return { + ...newState, + info: { + ...newState.info, + photoURL: null, + }, + }; +} + +/** + * Handles PROFILE/UPDATE_PROFILE_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onUpdateProfileDone(state, { payload, error }) { + const newState = { ...state, updatingProfile: false }; + + if (error) { + logger.error('Failed to update user profile', payload); + fireErrorMessage('ERROR: Failed to update user profile!'); + return newState; + } + + if (!newState.info || newState.info.handle !== payload.handle) { + return newState; + } + + return { + ...newState, + info: { + ...newState.info, + ...payload, + }, + }; +} + +/** + * Handles PROFILE/ADD_SKILL_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onAddSkillDone(state, { payload, error }) { + const newState = { ...state, addingSkill: false }; + + if (error) { + logger.error('Failed to add user skill', payload); + fireErrorMessage('ERROR: Failed to add user skill!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle) { + return newState; + } + + return { + ...newState, + skills: payload.skills, + }; +} + +/** + * Handles PROFILE/HIDE_SKILL_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onHideSkillDone(state, { payload, error }) { + const newState = { ...state, hidingSkill: false }; + + if (error) { + logger.error('Failed to remove user skill', payload); + fireErrorMessage('ERROR: Failed to remove user skill!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle) { + return newState; + } + + return { + ...newState, + skills: payload.skills, + }; +} + +/** + * Handles PROFILE/ADD_WEB_LINK_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onAddWebLinkDone(state, { payload, error }) { + const newState = { ...state, addingWebLink: false }; + + if (error) { + logger.error('Failed to add web link', payload); + fireErrorMessage('ERROR: Failed to add web link!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle || !payload.data) { + return newState; + } + + return { + ...newState, + externalLinks: [...newState.externalLinks, payload.data], + }; +} + +/** + * Handles PROFILE/DELETE_WEB_LINK_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onDeleteWebLinkDone(state, { payload, error }) { + const newState = { ...state, deletingWebLink: false }; + + if (error) { + logger.error('Failed to delete web link', payload); + fireErrorMessage('ERROR: Failed to delete web link!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle || !payload.data) { + return newState; + } + + return { + ...newState, + externalLinks: _.filter(newState.externalLinks, el => el.key !== payload.data.key), + }; +} + +/** + * Handles PROFILE/LINK_EXTERNAL_ACCOUNT_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onLinkExternalAccountDone(state, { payload, error }) { + const newState = { ...state, linkingExternalAccount: false }; + + if (error) { + logger.error('Failed to link external account', payload); + fireErrorMessage('ERROR: Failed to link external account!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle || !payload.data) { + return newState; + } + + return { + ...newState, + linkedAccounts: [...newState.linkedAccounts, payload.data], + }; +} + +/** + * Handles PROFILE/UNLINK_EXTERNAL_ACCOUNT_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onUnlinkExternalAccountDone(state, { payload, error }) { + const newState = { ...state, unlinkingExternalAccount: false }; + + if (error) { + logger.error('Failed to unlink external account', payload); + fireErrorMessage('ERROR: Failed to unlink external account!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle) { + return newState; + } + + return { + ...newState, + linkedAccounts: _.filter( + newState.linkedAccounts, + el => el.providerType !== payload.providerType, + ), + }; +} + +/** + * Handles PROFILE/SAVE_EMAIL_PREFERENCES_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onSaveEmailPreferencesDone(state, { payload, error }) { + const newState = { ...state, savingEmailPreferences: false }; + + if (error) { + logger.error('Failed to save email preferences', payload); + fireErrorMessage('ERROR: Failed to save email preferences!'); + return newState; + } + + if (newState.profileForHandle !== payload.handle || !payload.data) { + return newState; + } + + return { + ...newState, + emailPreferences: payload.data.subscriptions, + }; +} + +/** + * Handles PROFILE/UPDATE_PASSWORD_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onUpdatePasswordDone(state, { payload, error }) { + const newState = { ...state, updatingPassword: false }; + + if (error) { + logger.error('Failed to update password', payload); + } + return newState; +} + /** * Creates a new Profile reducer with the specified initial state. * @param {Object} initialState Optional. Initial state. @@ -124,6 +459,36 @@ function create(initialState) { [a.getSkillsDone]: onGetSkillsDone, [a.getStatsInit]: state => state, [a.getStatsDone]: onGetStatsDone, + [a.getLinkedAccountsInit]: state => state, + [a.getLinkedAccountsDone]: onGetLinkedAccountsDone, + [a.getActiveChallengesCountInit]: state => state, + [a.getActiveChallengesCountDone]: onGetActiveChallengesCountDone, + [a.uploadPhotoInit]: state => ({ ...state, uploadingPhoto: true }), + [a.uploadPhotoDone]: onUploadPhotoDone, + [a.deletePhotoInit]: state => ({ ...state, deletingPhoto: true }), + [a.deletePhotoDone]: onDeletePhotoDone, + [a.updateProfileInit]: state => ({ ...state, updatingProfile: true }), + [a.updateProfileDone]: onUpdateProfileDone, + [a.addSkillInit]: state => ({ ...state, addingSkill: true }), + [a.addSkillDone]: onAddSkillDone, + [a.hideSkillInit]: state => ({ ...state, hidingSkill: true }), + [a.hideSkillDone]: onHideSkillDone, + [a.addWebLinkInit]: state => ({ ...state, addingWebLink: true }), + [a.addWebLinkDone]: onAddWebLinkDone, + [a.deleteWebLinkInit]: state => ({ ...state, deletingWebLink: true }), + [a.deleteWebLinkDone]: onDeleteWebLinkDone, + [a.linkExternalAccountInit]: state => ({ ...state, linkingExternalAccount: true }), + [a.linkExternalAccountDone]: onLinkExternalAccountDone, + [a.unlinkExternalAccountInit]: state => ({ ...state, unlinkingExternalAccount: true }), + [a.unlinkExternalAccountDone]: onUnlinkExternalAccountDone, + [a.getCredentialInit]: state => state, + [a.getCredentialDone]: onGetCredentialDone, + [a.getEmailPreferencesInit]: state => state, + [a.getEmailPreferencesDone]: onGetEmailPreferencesDone, + [a.saveEmailPreferencesInit]: state => ({ ...state, savingEmailPreferences: true }), + [a.saveEmailPreferencesDone]: onSaveEmailPreferencesDone, + [a.updatePasswordInit]: state => ({ ...state, updatingPassword: true }), + [a.updatePasswordDone]: onUpdatePasswordDone, }, _.defaults(initialState, { achievements: null, copilot: false, diff --git a/src/services/api.js b/src/services/api.js index df363b7d..3d98c3dd 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -170,6 +170,29 @@ class Api { return this.put(endpoint, JSON.stringify(json)); } + /** + * Sends PATCH request to the specified endpoint. + * @param {String} endpoint + * @param {Blob|BufferSource|FormData|String} body + * @return {Promise} + */ + patch(endpoint, body) { + return this.fetch(endpoint, { + body, + method: 'PATCH', + }); + } + + /** + * Sends PATCH request to the specified endpoint. + * @param {String} endpoint + * @param {JSON} json + * @return {Promise} + */ + patchJson(endpoint, json) { + return this.patch(endpoint, JSON.stringify(json)); + } + /** * Upload with progress * @param {String} endpoint diff --git a/src/services/index.js b/src/services/index.js index af222abd..a71a3306 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -12,6 +12,7 @@ import * as communities from './communities'; import * as reviewOpportunities from './reviewOpportunities'; import * as userSetting from './user-settings'; import * as user from './user'; +import * as lookup from './lookup'; export const services = { api, @@ -25,6 +26,7 @@ export const services = { user, userSetting, reviewOpportunities, + lookup, }; export default undefined; diff --git a/src/services/lookup.js b/src/services/lookup.js new file mode 100644 index 00000000..60a7e480 --- /dev/null +++ b/src/services/lookup.js @@ -0,0 +1,47 @@ +/** + * @module "services.lookup" + * @desc This module provides a service to get lookup data from Topcoder + * via API V3. + */ +import qs from 'qs'; +import { getApiResponsePayloadV3 } from '../utils/tc'; +import { getApiV3 } from './api'; + +class LookupService { + /** + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + */ + constructor(tokenV3) { + this.private = { + api: getApiV3(tokenV3), + tokenV3, + }; + } + + /** + * Gets tags. + * @param {Object} params Parameters + * @return {Promise} Resolves to the tags. + */ + async getTags(params) { + const res = await this.private.api.get(`/tags/?${qs.stringify(params)}`); + return getApiResponsePayloadV3(res); + } +} + +let lastInstance = null; +/** + * Returns a new or existing lookup service. + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + * @return {LookupService} Lookup service object + */ +export function getService(tokenV3) { + if (!lastInstance || tokenV3 !== lastInstance.private.tokenV3) { + lastInstance = new LookupService(tokenV3); + } + return lastInstance; +} + +/* Using default export would be confusing in this case. */ +export default undefined; + diff --git a/src/services/members.js b/src/services/members.js index ba75e5c7..19948eff 100644 --- a/src/services/members.js +++ b/src/services/members.js @@ -4,6 +4,9 @@ * members via API V3. */ +/* global XMLHttpRequest */ +import _ from 'lodash'; +import logger from '../utils/logger'; import { getApiResponsePayloadV3 } from '../utils/tc'; import { getApiV3 } from './api'; @@ -96,6 +99,155 @@ class MembersService { const res = await this.private.api.get(`/members/_suggest/${keyword}`); return getApiResponsePayloadV3(res); } + + /** + * Adds external web link for member. + * @param {String} userHandle The user handle + * @param {String} webLink The external web link + * @return {Promise} Resolves to the api response content + */ + async addWebLink(userHandle, webLink) { + const res = await this.private.api.postJson(`/members/${userHandle}/externalLinks`, { param: { url: webLink } }); + return getApiResponsePayloadV3(res); + } + + /** + * Deletes external web link for member. + * @param {String} userHandle The user handle + * @param {String} webLinkHandle The external web link handle + * @return {Promise} Resolves to the api response content + */ + async deleteWebLink(userHandle, webLinkHandle) { + const body = { + param: { + handle: webLinkHandle, + }, + }; + const res = await this.private.api.delete(`/members/${userHandle}/externalLinks/${webLinkHandle}`, JSON.stringify(body)); + return getApiResponsePayloadV3(res); + } + + /** + * Adds user skill. + * @param {String} handle Topcoder user handle + * @param {Number} skillTagId Skill tag id + * @return {Promise} Resolves to operation result + */ + async addSkill(handle, skillTagId) { + const body = { + param: { + skills: { + [skillTagId]: { + hidden: false, + }, + }, + }, + }; + const res = await this.private.api.patchJson(`/members/${handle}/skills`, body); + return getApiResponsePayloadV3(res); + } + + /** + * Hides user skill. + * @param {String} handle Topcoder user handle + * @param {Number} skillTagId Skill tag id + * @return {Promise} Resolves to operation result + */ + async hideSkill(handle, skillTagId) { + const body = { + param: { + skills: { + [skillTagId]: { + hidden: true, + }, + }, + }, + }; + const res = await this.private.api.fetch(`/members/${handle}/skills`, { + body: JSON.stringify(body), + method: 'PATCH', + }); + return getApiResponsePayloadV3(res); + } + + /** + * Updates member profile. + * @param {Object} profile The profile to update. + * @return {Promise} Resolves to the api response content + */ + async updateMemberProfile(profile) { + const res = await this.private.api.putJson(`/members/${profile.handle}`, { param: profile }); + return getApiResponsePayloadV3(res); + } + + /** + * Gets presigned url for member photo file. + * @param {String} userHandle The user handle + * @param {File} file The file to get its presigned url + * @return {Promise} Resolves to the api response content + */ + async getPresignedUrl(userHandle, file) { + const res = await this.private.api.postJson(`/members/${userHandle}/photoUploadUrl`, { param: { contentType: file.type } }); + const payload = await getApiResponsePayloadV3(res); + + return { + preSignedURL: payload.preSignedURL, + token: payload.token, + file, + userHandle, + }; + } + + /** + * Updates member photo. + * @param {Object} S3Response The response from uploadFileToS3() function. + * @return {Promise} Resolves to the api response content + */ + async updateMemberPhoto(S3Response) { + const res = await this.private.api.putJson(`/members/${S3Response.userHandle}/photo`, { param: S3Response.body }); + return getApiResponsePayloadV3(res); + } + + /** + * Uploads file to S3. + * @param {Object} presignedUrlResponse The presigned url response from + * getPresignedUrl() function. + * @return {Promise} Resolves to the api response content + */ + uploadFileToS3(presignedUrlResponse) { + _.noop(this); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('PUT', presignedUrlResponse.preSignedURL, true); + xhr.setRequestHeader('Content-Type', presignedUrlResponse.file.type); + + xhr.onreadystatechange = () => { + const { status } = xhr; + if (((status >= 200 && status < 300) || status === 304) && xhr.readyState === 4) { + resolve({ + userHandle: presignedUrlResponse.userHandle, + body: { + token: presignedUrlResponse.token, + contentType: presignedUrlResponse.file.type, + }, + }); + } else if (status >= 400) { + const err = new Error('Could not upload image to S3'); + err.status = status; + reject(err); + } + }; + + xhr.onerror = (err) => { + logger.error('Could not upload image to S3', err); + + reject(err); + }; + + xhr.send(presignedUrlResponse.file); + }); + } } let lastInstance = null; diff --git a/src/services/user.js b/src/services/user.js index 88718a3a..3eddb2c5 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -3,10 +3,96 @@ * @desc The User service provides functionality related to Topcoder user * accounts. */ +import { config, isomorphy } from 'topcoder-react-utils'; +import logger from '../utils/logger'; import { getApiResponsePayloadV3 } from '../utils/tc'; import { getApiV2, getApiV3 } from './api'; +let auth0; +if (isomorphy.isClientSide()) { + const Auth0 = require('auth0-js'); /* eslint-disable-line global-require */ + auth0 = new Auth0({ + domain: config.AUTH0.DOMAIN, + clientID: config.AUTH0.CLIENT_ID, + callbackOnLocationHash: true, + sso: false, + }); +} + +/** + * Gets social user data. + * @param {Object} profile The user social profile + * @param {*} accessToken The access token + * @returns {Object} Social user data + */ +function getSocialUserData(profile, accessToken) { + const socialProvider = profile.identities[0].connection; + let firstName = ''; + let lastName = ''; + let handle = ''; + const email = profile.email || ''; + + const socialUserId = profile.user_id.substring(profile.user_id.lastIndexOf('|') + 1); + let splitName; + + if (socialProvider === 'google-oauth2') { + firstName = profile.given_name; + lastName = profile.family_name; + handle = profile.nickname; + } else if (socialProvider === 'facebook') { + firstName = profile.given_name; + lastName = profile.family_name; + handle = `${firstName}.${lastName}`; + } else if (socialProvider === 'twitter') { + splitName = profile.name.split(' '); + [firstName] = splitName; + if (splitName.length > 1) { + [, lastName] = splitName; + } + handle = profile.screen_name; + } else if (socialProvider === 'github') { + splitName = profile.name.split(' '); + [firstName] = splitName; + if (splitName.length > 1) { + [, lastName] = splitName; + } + handle = profile.nickname; + } else if (socialProvider === 'bitbucket') { + firstName = profile.first_name; + lastName = profile.last_name; + handle = profile.username; + } else if (socialProvider === 'stackoverflow') { + firstName = profile.first_name; + lastName = profile.last_name; + handle = socialUserId; + } else if (socialProvider === 'dribbble') { + firstName = profile.first_name; + lastName = profile.last_name; + handle = socialUserId; + } + + let token = accessToken; + let tokenSecret = null; + if (profile.identities[0].access_token) { + token = profile.identities[0].access_token; + } + if (profile.identities[0].access_token_secret) { + tokenSecret = profile.identities[0].access_token_secret; + } + return { + socialUserId, + username: handle, + firstname: firstName, + lastname: lastName, + email, + socialProfile: profile, + socialProvider, + accessToken: token, + accessTokenSecret: tokenSecret, + }; +} + /** * Service class. */ @@ -60,6 +146,163 @@ class User { const res = await this.private.api.get(url); return (await getApiResponsePayloadV3(res))[0]; } + + /** + * Gets email preferences. + * + * NOTE: Only admins are authorized to use the underlying endpoint. + * + * @param {Number} userId The TopCoder user id + * @returns {Promise} Resolves to the email preferences result + */ + async getEmailPreferences(userId) { + const url = `/users/${userId}/preferences/email`; + const res = await this.private.api.get(url); + return getApiResponsePayloadV3(res); + } + + /** + * Saves email preferences. + * + * NOTE: Only admins are authorized to use the underlying endpoint. + * + * @param {Object} user The TopCoder user + * @param {Object} preferences The email preferences + * @returns {Promise} Resolves to the email preferences result + */ + async saveEmailPreferences({ firstName, lastName, userId }, preferences) { + const settings = { + firstName, + lastName, + subscriptions: {}, + }; + + if (!preferences) { + settings.subscriptions.TOPCODER_NL_GEN = true; + } else { + settings.subscriptions = preferences; + } + const url = `/users/${userId}/preferences/email`; + + const res = await this.private.api.putJson(url, { param: settings }); + return getApiResponsePayloadV3(res); + } + + /** + * Gets credential for the specified user id. + * + * NOTE: Only admins are authorized to use the underlying endpoint. + * + * @param {Number} userId The user id + * @return {Promise} Resolves to the linked accounts array. + */ + async getCredential(userId) { + const url = `/users/${userId}?fields=credential`; + const res = await this.private.api.get(url); + return getApiResponsePayloadV3(res); + } + + /** + * Updates user password. + * + * NOTE: Only admins are authorized to use the underlying endpoint. + * + * @param {Number} userId The user id + * @param {String} newPassword The new password + * @param {String} oldPassword The old password + * @return {Promise} Resolves to the update result. + */ + async updatePassword(userId, newPassword, oldPassword) { + const credential = { + password: newPassword, + currentPassword: oldPassword, + }; + + const url = `/users/${userId}`; + const res = await this.private.api.patchJson(url, { param: { credential } }); + return getApiResponsePayloadV3(res); + } + + /** + * Gets linked accounts for the specified user id. + * + * NOTE: Only admins are authorized to use the underlying endpoint. + * + * @param {Number} userId The user id + * @return {Promise} Resolves to the linked accounts array. + */ + async getLinkedAccounts(userId) { + const url = `/users/${userId}?fields=profiles`; + const res = await this.private.api.get(url); + return getApiResponsePayloadV3(res); + } + + /** + * Unlinks external account. + * @param {Number} userId The TopCoder user id + * @param {String} provider The external account service provider + * @returns {Promise} Resolves to the unlink result + */ + async unlinkExternalAccount(userId, provider) { + const url = `/users/${userId}/profiles/${provider}`; + const res = await this.private.api.delete(url); + return getApiResponsePayloadV3(res); + } + + /** + * Links external account. + * @param {Number} userId The TopCoder user id + * @param {String} provider The external account service provider + * @param {String} callbackUrl Optional. The callback url + * @returns {Promise} Resolves to the linked account result + */ + async linkExternalAccount(userId, provider, callbackUrl) { + return new Promise((resolve, reject) => { + auth0.signin( + { + popup: true, + connection: provider, + scope: 'openid profile offline_access', + state: callbackUrl, + }, + (authError, profile, idToken, accessToken) => { + if (authError) { + logger.error('Error signing in - onSocialLoginFailure', authError); + reject(authError); + return; + } + + const socialData = getSocialUserData(profile, accessToken); + + const postData = { + userId: socialData.socialUserId, + name: socialData.username, + email: socialData.email, + emailVerified: false, + providerType: socialData.socialProvider, + context: { + handle: socialData.username, + accessToken: socialData.accessToken, + auth0UserId: profile.user_id, + }, + }; + if (socialData.accessTokenSecret) { + postData.context.accessTokenSecret = socialData.accessTokenSecret; + } + logger.debug(`link API postdata: ${JSON.stringify(postData)}`); + this.private.api.postJson(`/users/${userId}/profiles`, { param: postData }) + .then(resp => getApiResponsePayloadV3(resp).then((result) => { + logger.debug(`Succesfully linked account: ${JSON.stringify(result)}`); + resolve(postData); + })) + .catch((err) => { + logger.error('Error linking account', err); + reject(err); + }); + }, + ); + }); + } } let lastInstance = null;