Skip to content

Feature member search list #137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions __tests__/__snapshots__/index.js.snap
Original file line number Diff line number Diff line change
@@ -65,6 +65,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],
@@ -238,6 +249,7 @@ Object {
"groups": [Function],
"looker": [Function],
"lookup": [Function],
"memberSearch": [Function],
"memberTasks": [Function],
"members": [Function],
"mySubmissionsManagement": [Function],
@@ -290,6 +302,10 @@ Object {
"default": undefined,
"getService": [Function],
},
"memberSearch": Object {
"default": undefined,
"getService": [Function],
},
"members": Object {
"default": undefined,
"getService": [Function],
2 changes: 2 additions & 0 deletions src/actions/index.js
Original file line number Diff line number Diff line change
@@ -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;
80 changes: 80 additions & 0 deletions src/actions/member-search.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
4 changes: 3 additions & 1 deletion src/reducers/index.js
Original file line number Diff line number Diff line change
@@ -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,
});
245 changes: 245 additions & 0 deletions src/reducers/member-search.js
Original file line number Diff line number Diff line change
@@ -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();
2 changes: 2 additions & 0 deletions src/services/index.js
Original file line number Diff line number Diff line change
@@ -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;
121 changes: 121 additions & 0 deletions src/services/member-search.js
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions src/utils/member-search.js
Original file line number Diff line number Diff line change
@@ -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;
}