diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 1da4407c..31e05077 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -67,6 +67,17 @@ Object { "getSkillTagsDone": [Function], "getSkillTagsInit": [Function], }, + "memberSearch": Object { + "checkIfSearchTermIsATag": [Function], + "clearMemberSearch": [Function], + "loadMoreUsernames": [Function], + "memberSearchSuccess": [Function], + "resetSearchTerm": [Function], + "setSearchTag": [Function], + "setSearchTerm": [Function], + "topMemberSearchSuccess": [Function], + "usernameSearchSuccess": [Function], + }, "memberTasks": Object { "dropAll": [Function], "getDone": [Function], @@ -240,6 +251,7 @@ Object { "groups": [Function], "looker": [Function], "lookup": [Function], + "memberSearch": [Function], "memberTasks": [Function], "members": [Function], "mySubmissionsManagement": [Function], @@ -292,6 +304,10 @@ Object { "default": undefined, "getService": [Function], }, + "memberSearch": Object { + "default": undefined, + "getService": [Function], + }, "members": Object { "default": undefined, "getService": [Function], diff --git a/package.json b/package.json index b8e64b88..c04041ab 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint:js": "./node_modules/.bin/eslint --ext .js,.jsx .", "test": "npm run lint && npm run jest" }, - "version": "0.11.1", + "version": "0.12.0", "dependencies": { "auth0-js": "^6.8.4", "config": "^3.2.0", diff --git a/src/actions/index.js b/src/actions/index.js index 8b1a241b..09deaca7 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -13,6 +13,7 @@ import reviewOpportunityActions from './reviewOpportunity'; import lookupActions from './lookup'; import settingsActions from './settings'; import lookerActions from './looker'; +import memberSearchActions from './member-search'; export const actions = { auth: authActions.auth, @@ -30,6 +31,7 @@ export const actions = { lookup: lookupActions.lookup, settings: settingsActions.settings, looker: lookerActions.looker, + memberSearch: memberSearchActions.memberSearch, }; export default undefined; diff --git a/src/actions/member-search.js b/src/actions/member-search.js new file mode 100644 index 00000000..7b5694bd --- /dev/null +++ b/src/actions/member-search.js @@ -0,0 +1,80 @@ +/** + * @module "actions.member-search" + * @desc Actions for management of members search. + */ +import _ from 'lodash'; +import { createActions } from 'redux-actions'; +import { getService } from '../services/member-search'; + +/** + * @desc Creates an action that fetchs the members data for a search term, and + * adds result to the store cumulatively. + * @param {String} searchTerm the search term + * @param {Number} offset the number of records to skip + * @param {Number} limit the maximum number of the return results + * @return {Action} + */ +function loadMemberSearch(searchTerm, offset = 0, limit = 10) { + return getService().getUsernameMatches(searchTerm, offset, limit); +} + +/** + * @static + * @desc Creates an action that fetchs the members data for a search tag, and + * adds result to the store. + * @param {Object} tag the tag + * @return {Action} + */ +function loadMemberSearchForTag(tag) { + return getService().getTopMembers(tag); +} + +/** + * @static + * @desc Creates an action that check if the term is a tag name. If it is unable to check, + * or invalid data returned then resets the members data and search terms data in the store + * to intial values. + * @param {String} searchTerm the search term + * @return {Action} + */ +function checkIfSearchTermIsATag(searchTerm) { + return getService().checkIfSearchTermIsATag(searchTerm); +} + +/** + * @static + * @desc Creates an action that saves the current search term. + * @param {String} searchTerm the search term + * @return {Action} + */ +function setSearchTerm(searchTerm) { + return { + previousSearchTerm: searchTerm, + }; +} + +/** + * @static + * @desc Creates an action that saves the current search tag. + * @param {Object} searchTag the search tag + * @return {Action} + */ +function setSearchTag(searchTag) { + return { + searchTermTag: searchTag, + }; +} + +export default createActions({ + MEMBER_SEARCH: { + USERNAME_SEARCH_SUCCESS: loadMemberSearch, + CHECK_IF_SEARCH_TERM_IS_A_TAG: checkIfSearchTermIsATag, + TOP_MEMBER_SEARCH_SUCCESS: loadMemberSearchForTag, + CLEAR_MEMBER_SEARCH: _.noop, + LOAD_MORE_USERNAMES: _.noop, + MEMBER_SEARCH_SUCCESS: _.noop, + SET_SEARCH_TERM: setSearchTerm, + SET_SEARCH_TAG: setSearchTag, + RESET_SEARCH_TERM: _.noop, + }, +}); diff --git a/src/reducers/index.js b/src/reducers/index.js index 15fd144c..7f7e3604 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -22,7 +22,7 @@ import settings, { factory as settingsFactory } from './settings'; import looker, { factory as lookerFactory } from './looker'; - +import memberSearch, { factory as memberSearchFactory } from './member-search'; export function factory(options) { return redux.resolveReducers({ @@ -41,6 +41,7 @@ export function factory(options) { mySubmissionsManagement: mySubmissionsManagementFactory(options), settings: settingsFactory(options), looker: lookerFactory(options), + memberSearch: memberSearchFactory(options), }); } @@ -60,4 +61,5 @@ export default ({ mySubmissionsManagement, settings, looker, + memberSearch, }); diff --git a/src/reducers/member-search.js b/src/reducers/member-search.js new file mode 100644 index 00000000..bc57ae3b --- /dev/null +++ b/src/reducers/member-search.js @@ -0,0 +1,245 @@ +/** + * @module "reducers.member-search" + * @desc Reducer for {@link module:actions.member-search} actions. + * + * State segment managed by this reducer has the following structure: + * @param {Boolean} pageLoaded `true` if loading members data for a search term is done + * `false`if starting loading members data for a search term, + * or loading failed + * @param {Boolean} loadingMore `true` if request for loading more data is in progress; + * otherwise `false` + * @param {Boolean} error `true` if failed to load member data; otherwise `false` + * @param {Number} totalCount the number of matched members for a search term + * @param {Boolean} moreMatchesAvailable `true` if there are more matched members, for + * a search term, to load; otherwise `false` + * @param {Array<{}>} usernameMatches contains loaded members data for a search term + * @param {Array<{}>} topMembers contains loaded members data for a search tag + * @param {String} previousSearchTerm the current search term + * @param {Object} searchTermTag the current search tag data if the search term is a tag name; + * otherwise `null` + */ +import _ from 'lodash'; +import { redux } from 'topcoder-react-utils'; +import actions from '../actions/member-search'; +import { fireErrorMessage } from '../utils/errors'; + +/** + * @private + * Returns the new state with the intial members data. + */ +function memberSearchFailure(state) { + return Object.assign({}, state, { + loadingMore: false, + error: true, + totalCount: 0, + usernameMatches: [], + topMembers: [], + }); +} + +/** + * @private + * Returns the new state with the intial search terms data. + */ +function resetSearchTerm(state) { + return Object.assign({}, state, { + pageLoaded: false, + previousSearchTerm: null, + searchTermTag: null, + }); +} + +/** + * @private + * Returns the new state with the intial members and search terms data. + */ +function memberSearchFailureAndResetSearchTerm(state) { + let newState = state; + newState = memberSearchFailure(newState); + newState = resetSearchTerm(newState); + return newState; +} + +/** + * Handles the actual results of loading members data for a search term cumulatively, + * and clear members data on request failure. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onUsernameSearchSuccess(state, action) { + const { payload } = action; + if (action.error) { + fireErrorMessage('Could not fetch username matches', ''); + return memberSearchFailure(state); + } + + return Object.assign({}, state, { + loadingMore: false, + totalCount: payload.totalCount, + moreMatchesAvailable: state.usernameMatches.length + payload.usernameMatches.length + < payload.totalCount, + usernameMatches: state.usernameMatches.concat(payload.usernameMatches), + }); +} + +/** + * Clear members data and search terms data on request failure of checking if the search term is + * a tag name. + * @param {Object} state + * @param {Object} action + * @return {Object} New state if error; otherwise the same state. + */ +function onCheckIfSearchTermIsATag(state, action) { + if (action.error) { + fireErrorMessage('Could not determine if search term is a tag', ''); + return memberSearchFailureAndResetSearchTerm(state); + } + + return state; +} + +/** + * Handles the actual results of loading members data for a search tag, and + * clear members data and search terms data on request failure. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onTopMemberSearchSuccess(state, action) { + const { payload } = action; + if (action.error) { + fireErrorMessage('Could not fetch top members', ''); + return memberSearchFailureAndResetSearchTerm(state); + } + + return Object.assign({}, state, { + topMembers: payload.topMembers, + }); +} + +/** + * Clear members data to the intial state. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onClearMemberSearch(state) { + return Object.assign({}, state, { + pageLoaded: false, + loadingMore: false, + error: false, + totalCount: 0, + usernameMatches: [], + topMembers: [], + }); +} + +/** + * Marks the request of loading more members data for a search term as in progress + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onLoadMoreUsernames(state) { + return Object.assign({}, state, { + loadingMore: true, + }); +} + +/** + * Marks the loaded members data for a search term or search tag (if any) as ready. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onMemberSearchSuccess(state) { + return Object.assign({}, state, { + pageLoaded: true, + }); +} + +/** + * Handles setting the current search term. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onSetSearchTerm(state, action) { + const { payload } = action; + return Object.assign({}, state, { + error: false, + previousSearchTerm: payload.previousSearchTerm, + }); +} + +/** + * Handles setting the current search tag. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onSetSearchTag(state, action) { + const { payload } = action; + return Object.assign({}, state, { + searchTermTag: payload.searchTermTag, + }); +} + +/** + * Handles clearing the current search term and search tag. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onResetSearchTerm(state) { + return resetSearchTerm(state); +} + +/** + * Creates a new member search reducer with the specified initial state. + * @param {Object} initialState Optional. Initial state. + * @return {Function} member search reducer. + */ +function create(initialState = {}) { + const a = actions.memberSearch; + return redux.handleActions({ + [a.usernameSearchSuccess]: onUsernameSearchSuccess, + [a.checkIfSearchTermIsATag]: onCheckIfSearchTermIsATag, + [a.topMemberSearchSuccess]: onTopMemberSearchSuccess, + [a.clearMemberSearch]: onClearMemberSearch, + [a.loadMoreUsernames]: onLoadMoreUsernames, + [a.memberSearchSuccess]: onMemberSearchSuccess, + [a.setSearchTerm]: onSetSearchTerm, + [a.setSearchTag]: onSetSearchTag, + [a.resetSearchTerm]: onResetSearchTerm, + }, _.defaults(initialState, { + pageLoaded: false, + loadingMore: false, + error: false, + totalCount: 0, + moreMatchesAvailable: false, + usernameMatches: [], + topMembers: [], + previousSearchTerm: null, + searchTermTag: null, + })); +} + +/** + * Factory which creates a new reducer with its initial state tailored to the + * given options object, if specified (for server-side rendering). If options + * object is not specified, it creates just the default reducer. Accepted options are: + * @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/services/index.js b/src/services/index.js index 4d776832..d6b5993b 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -15,6 +15,7 @@ import * as user from './user'; import * as lookup from './lookup'; import * as userTraits from './user-traits'; import * as submissions from './submissions'; +import * as memberSearch from './member-search'; export const services = { api, @@ -31,6 +32,7 @@ export const services = { lookup, userTraits, submissions, + memberSearch, }; export default undefined; diff --git a/src/services/member-search.js b/src/services/member-search.js new file mode 100644 index 00000000..94f0ca25 --- /dev/null +++ b/src/services/member-search.js @@ -0,0 +1,121 @@ +/** + * @module "services.member-search" + * @desc This module provides a service for searching members. + */ +import _ from 'lodash'; +import qs from 'qs'; +import { getApi } from './api'; +import { checkResponseSucess, mapTagToLeaderboardType } from '../utils/member-search'; + +/** + * Member search service class. + */ +class MemberSearchService { + /** + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + */ + constructor(tokenV3) { + this.private = { + api: getApi('V3', tokenV3), + tokenV3, + }; + } + + /** + * Get matched members for a search term. + * @param {String} searchTerm the search term + * @param {Number} offset the number of members to skip from starting + * @param {Number} limit the maximum number of return members + * @return {Promise} Resolves to an object containing the array of matched members + * and the total count + */ + getUsernameMatches(searchTerm, offset, limit) { + const params = { + query: 'MEMBER_SEARCH', + handle: encodeURIComponent(searchTerm), + offset, + limit, + }; + + return this.private.api.get(`/members/_search/?${qs.stringify(params)}`) + .then(checkResponseSucess) + .then((data) => { + const usernameMatches = _.get(data, 'result.content'); + const totalCount = _.get(data, 'result.metadata.totalCount'); + + if (!_.isArray(usernameMatches)) { + throw new Error('Expected array for username response results'); + } else if (!_.isNumber(totalCount)) { + throw new Error('Expected number for metadata total count'); + } + + return { + usernameMatches, + totalCount, + }; + }) + .catch((err) => { + throw new Error(`Could not fetch username matches. Reason: ${err}`); + }); + } + + /** + * Check if the search term is a tag. + * @param {String} searchTerm the search term + * @return {Promise} Resolves to a tag object + */ + checkIfSearchTermIsATag(searchTerm) { + return this.private.api.get(`/tags/?filter=name%3D${encodeURIComponent(searchTerm)}`) + .then(checkResponseSucess) + .then((data) => { + const tagInfo = _.get(data, 'result.content'); + + if (!_.isArray(tagInfo)) { + throw new Error('Tag response must be an array'); + } + + return tagInfo[0]; + }) + .catch((err) => { + throw new Error(`Could not determine if search term is a tag. Reason: ${err}`); + }); + } + + /** + * Get matched members for a search tag. + * @param {Object} tag the tag + * @return {Promise} Resolves to an object containing the array of matched members + */ + getTopMembers(tag) { + const leaderboardType = mapTagToLeaderboardType(tag.domain); + + return this.private.api.get(`/leaderboards/?filter=id%3D${tag.id}%26type%3D${leaderboardType}`) + .then(checkResponseSucess) + .then((data) => { + const topMembers = _.get(data, 'result.content', []); + + return { + topMembers, + }; + }) + .catch((err) => { + throw new Error(`Could not fetch top members. Reason: ${err}`); + }); + } +} + +let lastInstance = null; + +/** + * Returns a new or existing member-search service. + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + * @return {MemberSearchService} Member search service object + */ +export function getService(tokenV3) { + if (!lastInstance || tokenV3 !== lastInstance.private.tokenV3) { + lastInstance = new MemberSearchService(tokenV3); + } + return lastInstance; +} + +export default undefined; diff --git a/src/utils/member-search.js b/src/utils/member-search.js new file mode 100644 index 00000000..1fba5b0b --- /dev/null +++ b/src/utils/member-search.js @@ -0,0 +1,18 @@ +export function mapTagToLeaderboardType(tagDomain) { + const tagToLeaderboardTypeMap = { + SKILLS: 'MEMBER_SKILL', + }; + + return tagDomain ? tagToLeaderboardTypeMap[tagDomain.toUpperCase()] : null; +} + +export async function checkResponseSucess(res) { + if (!res.ok) { + throw new Error(res.statusText); + } + const x = await res.json(); + if (!x.result.success) { + throw new Error(x.result.content); + } + return x; +}