From e8ef0521ccf1d3e1db08d705d3dfadbc417b8b49 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sat, 17 Jun 2017 17:08:47 +0200 Subject: [PATCH 01/20] Challenge listing is updated to load all active challenges at once Currently, backend allows to load at most 50 challenge objects by a single request. In prod we have typically around 30-50 active challenge at the same time, thus most of the time this change won't have any impact on the performance. Even if, eventually, there will be more than 50 active challenges, a few additional requests are not a big deal for the peformance. On the other hand, assuming that all active challenges are already loaded into the frontend allows to simplify a lot of code at the frontend side, and to provide a better and more consistent user experience when filtering&counting challenges in the challenge listing. --- src/shared/actions/challenge-listing.js | 70 +++++++++++++++++++ .../containers/ChallengeListing/index.jsx | 57 +++++++++------ src/shared/reducers/challenge-listing.js | 2 + 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/shared/actions/challenge-listing.js b/src/shared/actions/challenge-listing.js index 695e20e52a..f519b34aa3 100644 --- a/src/shared/actions/challenge-listing.js +++ b/src/shared/actions/challenge-listing.js @@ -30,6 +30,39 @@ import logger from 'utils/logger'; import { createActions } from 'redux-actions'; import { getService } from 'services/challenges'; +/** + * Private. Common logic to get all challenge or marathon matches. + * @param {Function} getter getChallenges(..) or getMarathonMatches(..) + * @param {String} uuid + * @param {Object} filters + * @param {String} token + * @param {String} user + * @return {Promise} + */ +function getAll(getter, uuid, filters, token, countCategory, user) { + /* API does not allow to get more than 50 challenges or MMs a time. */ + const LIMIT = 50; + let page = 0; + let res; + + /* Single iteration of the fetch procedure. */ + function iteration() { + return getter(uuid, filters, { + limit: LIMIT, + offset: LIMIT * page, + }, token, countCategory || 'count', user).then((next) => { + if (res) res.challenges = res.challenges.concat(next.challenges); + else res = next; + page += 1; + if (LIMIT * page < res.totalCount.value) return iteration(); + if (!countCategory) res.totalCount = null; + return res; + }); + } + + return iteration(); +} + /** * Private. Common processing of promises returned from ChallengesService. * @param {Object} promise @@ -112,6 +145,23 @@ function getChallenges(uuid, filters, params, token, countCategory, user) { return handle(promise, uuid, filters, countCategory, user); } +/** + * Calls getChallenges(..) recursively to get all challenges matching given + * arguments. Mind that API does not allow to get more than 50 challenges a + * time. You should use this function carefully, and never call it when there + * might be many challenges matching your request. It is originally intended + * to get all active challenges, as there never too many of them. + * @param {String} uuid + * @param {Object} filters + * @param {String} token + * @param {String} countCategory + * @param {String} user + * @return {Promise} + */ +function getAllChallenges(uuid, filters, token, countCategory, user) { + return getAll(getChallenges, uuid, filters, token, countCategory, user); +} + /** * Writes specified UUID into the set of pending requests to load challenges. * This allows (1) to understand whether we are waiting to load any challenges; @@ -142,6 +192,23 @@ function getMarathonMatches(uuid, filters, params, token, countCategory, user) { return handle(promise, uuid, filters, countCategory, user); } +/** + * Calls getMarathonMatches(..) recursively to get all challenges matching given + * arguments. Mind that API does not allow to get more than 50 challenges a + * time. You should use this function carefully, and never call it when there + * might be many challenges matching your request. It is originally intended + * to get all active challenges, as there never too many of them. + * @param {String} uuid + * @param {Object} filters + * @param {String} token + * @param {String} countCategory + * @param {String} user + * @return {Promise} + */ +function getAllMarathonMatches(uuid, filters, token, countCategory, user) { + return getAll(getMarathonMatches, uuid, filters, token, countCategory, user); +} + /** * This action tells Redux to remove all loaded challenges and to cancel * any pending requests to load more challenges. @@ -161,6 +228,9 @@ function setFilter(filter) { export default createActions({ CHALLENGE_LISTING: { + GET_ALL_CHALLENGES: getAllChallenges, + GET_ALL_MARATHON_MATCHES: getAllMarathonMatches, + GET_CHALLENGE_SUBTRACKS_INIT: _.noop, GET_CHALLENGE_SUBTRACKS_DONE: getChallengeSubtracksDone, GET_CHALLENGE_TAGS_INIT: _.noop, diff --git a/src/shared/containers/ChallengeListing/index.jsx b/src/shared/containers/ChallengeListing/index.jsx index 9569905615..1825e4eb87 100644 --- a/src/shared/containers/ChallengeListing/index.jsx +++ b/src/shared/containers/ChallengeListing/index.jsx @@ -86,31 +86,23 @@ class ChallengeListingPageContainer extends React.Component { loadChallenges() { const { tokenV3, user } = this.props.auth; - /* Active challenges. */ - this.props.getChallenges({ - status: 'ACTIVE', - }, {}, tokenV3, 'active'); - this.props.getMarathonMatches({ - status: 'ACTIVE', - }, {}, tokenV3, 'activeMM'); - - /* My active challenges. */ + /* Gets all active challenges. */ + this.props.getAllChallenges({ status: 'ACTIVE' }, tokenV3); + this.props.getAllMarathonMatches({ status: 'ACTIVE' }, tokenV3); + + /* Gets all active challenges, where the vistor is participant. */ if (user) { - this.props.getChallenges({ + this.props.getAllChallenges({ status: 'ACTIVE', - }, {}, tokenV3, 'myActive', user.handle); - this.props.getMarathonMatches({ + }, tokenV3, null, user.handle); + this.props.getAllMarathonMatches({ status: 'ACTIVE', - }, {}, tokenV3, 'myActiveMM', user.handle); + }, tokenV3, null, user.handle); } - /* Past challenges. */ - this.props.getChallenges({ - status: 'COMPLETED', - }, {}, tokenV3, 'past'); - this.props.getMarathonMatches({ - status: 'PAST', - }, {}, tokenV3, 'pastMM'); + /* Gets some (50 + 50) past challenges and MMs. */ + this.props.getChallenges({ status: 'COMPLETED' }, {}, tokenV3); + this.props.getMarathonMatches({ status: 'PAST' }, {}, tokenV3); } /** @@ -216,6 +208,8 @@ ChallengeListingPageContainer.propTypes = { pendingRequests: PT.shape({}).isRequired, }).isRequired, communityName: PT.string, + getAllChallenges: PT.func.isRequired, + getAllMarathonMatches: PT.func.isRequired, getChallenges: PT.func.isRequired, getChallengeSubtracks: PT.func.isRequired, getChallengeTags: PT.func.isRequired, @@ -252,6 +246,26 @@ const mapStateToProps = state => ({ }, }); +/** + * Loads into redux all challenges matching the request. + * @param {Function} dispatch + */ +function getAllChallenges(dispatch, ...rest) { + const uuid = shortid(); + dispatch(actions.challengeListing.getInit(uuid)); + dispatch(actions.challengeListing.getAllChallenges(uuid, ...rest)); +} + +/** + * Loads into redux all MMs matching the request. + * @param {Function} dispatch + */ +function getAllMarathonMatches(dispatch, ...rest) { + const uuid = shortid(); + dispatch(actions.challengeListing.getInit(uuid)); + dispatch(actions.challengeListing.getAllMarathonMatches(uuid, ...rest)); +} + /** * Callback for loading challenges satisfying to the specified criteria. * All arguments starting from second should match corresponding arguments @@ -289,6 +303,9 @@ function mapDispatchToProps(dispatch) { const a = actions.challengeListing; const ah = headerActions.topcoderHeader; return { + getAllChallenges: (...rest) => getAllChallenges(dispatch, ...rest), + getAllMarathonMatches: (...rest) => + getAllMarathonMatches(dispatch, ...rest), getChallenges: (...rest) => getChallenges(dispatch, ...rest), getChallengeSubtracks: () => { dispatch(a.getChallengeSubtracksInit()); diff --git a/src/shared/reducers/challenge-listing.js b/src/shared/reducers/challenge-listing.js index c800eeb54c..387d2abd6d 100644 --- a/src/shared/reducers/challenge-listing.js +++ b/src/shared/reducers/challenge-listing.js @@ -276,6 +276,8 @@ function onReset(state) { function create(initialState) { const a = actions.challengeListing; return handleActions({ + [a.getAllChallenges]: onGetChallenges, + [a.getAllMarathonMatches]: onGetMarathonMatches, [a.getChallengeSubtracksDone]: onGetChallengeSubtracksDone, [a.getChallengeSubtracksInit]: state => ({ ...state, From 2c83afb4865966f29c45b57744098a068f7ea60f Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sat, 17 Jun 2017 23:45:04 +0200 Subject: [PATCH 02/20] Track switches are rewired to the new filter and connected to Redux --- src/shared/actions/challenge-listing.js | 1 + .../Filters/ChallengeFilters.jsx | 35 +++++- .../components/challenge-listing/index.jsx | 8 ++ .../containers/ChallengeListing/index.jsx | 5 + src/shared/reducers/challenge-listing.js | 10 +- src/shared/utils/challenge-listing/filter.js | 104 ++++++++++++++++++ src/shared/utils/tc.js | 2 +- 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/shared/utils/challenge-listing/filter.js diff --git a/src/shared/actions/challenge-listing.js b/src/shared/actions/challenge-listing.js index f519b34aa3..e9dcfa46a1 100644 --- a/src/shared/actions/challenge-listing.js +++ b/src/shared/actions/challenge-listing.js @@ -241,5 +241,6 @@ export default createActions({ GET_MARATHON_MATCHES: getMarathonMatches, RESET: reset, SET_FILTER: setFilter, + SET_FILTER_STATE: _.identity, }, }); diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 27d1c96306..151afd4a9e 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -42,6 +42,8 @@ import _ from 'lodash'; import React from 'react'; import PT from 'prop-types'; import SwitchWithLabel from 'components/SwitchWithLabel'; +import * as Filter from 'utils/challenge-listing/filter'; +import { COMPETITION_TRACKS as TRACKS } from 'utils/tc'; import ChallengeFilter, { DATA_SCIENCE_TRACK, DESIGN_TRACK, DEVELOP_TRACK } from './ChallengeFilter'; import ChallengeSearchBar from './ChallengeSearchBar'; @@ -165,6 +167,7 @@ class ChallengeFilters extends React.Component { } render() { + const { filterState, setFilterState } = this.props; return (
@@ -182,23 +185,41 @@ class ChallengeFilters extends React.Component { this.setTracks(DESIGN_TRACK, enable)} + onSwitch={(on) => { + const act = on ? Filter.addTrack : Filter.removeTrack; + setFilterState(act(filterState, TRACKS.DESIGN)); + }} /> this.setTracks(DEVELOP_TRACK, enable)} + onSwitch={(on) => { + const act = on ? Filter.addTrack : Filter.removeTrack; + setFilterState(act(filterState, TRACKS.DEVELOP)); + }} /> this.setTracks(DATA_SCIENCE_TRACK, enable)} + onSwitch={(on) => { + const act = on ? Filter.addTrack : Filter.removeTrack; + setFilterState(act(filterState, TRACKS.DATA_SCIENCE)); + }} /> @@ -278,12 +299,14 @@ ChallengeFilters.propTypes = { challengeGroupId: PT.string.isRequired, communityName: PT.string, filter: PT.instanceOf(ChallengeFilter), + filterState: PT.shape().isRequired, isCardTypeSet: PT.string, searchQuery: PT.string, onFilter: PT.func, onSearch: PT.func, onSaveFilter: PT.func, setCardType: PT.func, + setFilterState: PT.func.isRequired, validKeywords: PT.arrayOf(TagShape).isRequired, validSubtracks: PT.arrayOf(TagShape).isRequired, }; diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index 872a9c50e6..ca6459a9fa 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -18,6 +18,7 @@ import React from 'react'; import PT from 'prop-types'; import config from 'utils/config'; import Sticky from 'react-stickynode'; +import * as Filter from 'utils/challenge-listing/filter'; import ChallengeFilterWithSearch from './Filters/ChallengeFilterWithSearch'; import ChallengeFilters from './Filters/ChallengeFilters'; @@ -235,6 +236,9 @@ class ChallengeFiltersExample extends React.Component { }); } + challenges = challenges.filter( + Filter.getFilterFunction(this.props.filterState)); + challenges.sort((a, b) => b.submissionEndDate - a.submissionEndDate); const filter = this.getFilter(); @@ -332,6 +336,7 @@ class ChallengeFiltersExample extends React.Component {
this.onFilterByTopFilter(topFilter)} onSaveFilter={(filterToSave) => { if (this.sidebar) { @@ -347,6 +352,7 @@ class ChallengeFiltersExample extends React.Component { validKeywords={this.props.challengeTags.map(keywordsMapper)} validSubtracks={this.props.challengeSubtracks.map(keywordsMapper)} setCardType={_.noop/* cardType => this.setCardType(cardType) */} + setFilterState={this.props.setFilterState} isCardTypeSet={'Challenges' /* this.state.currentCardType */} ref={(node) => { this.challengeFilters = node; }} /> @@ -445,10 +451,12 @@ ChallengeFiltersExample.propTypes = { challengeTags: PT.arrayOf(PT.string).isRequired, communityName: PT.string, filter: PT.string.isRequired, + filterState: PT.shape().isRequired, getChallenges: PT.func.isRequired, getMarathonMatches: PT.func.isRequired, loadingChallenges: PT.bool.isRequired, setFilter: PT.func.isRequired, + setFilterState: PT.func.isRequired, /* OLD PROPS BELOW */ config: PT.shape({ diff --git a/src/shared/containers/ChallengeListing/index.jsx b/src/shared/containers/ChallengeListing/index.jsx index 1825e4eb87..4954ef1edc 100644 --- a/src/shared/containers/ChallengeListing/index.jsx +++ b/src/shared/containers/ChallengeListing/index.jsx @@ -163,6 +163,7 @@ class ChallengeListingPageContainer extends React.Component { challengeTags={cl.challengeTags} communityName={this.props.communityName} filter={this.props.challengeListing.filter} + filterState={this.props.challengeListing.filterState} getChallenges={this.props.getChallenges} getMarathonMatches={this.props.getMarathonMatches} loadingChallenges={Boolean(_.keys(this.props.challengeListing.pendingRequests).length)} @@ -173,6 +174,7 @@ class ChallengeListingPageContainer extends React.Component { this.props.setFilter(f); } }} + setFilterState={this.props.setFilterState} /* OLD PROPS BELOW */ challengeGroupId={challengeGroupId} @@ -205,6 +207,7 @@ ChallengeListingPageContainer.propTypes = { challengeListing: PT.shape({ challenges: PT.arrayOf(PT.shape({})).isRequired, filter: PT.string.isRequired, + filterState: PT.shape.isRequired, pendingRequests: PT.shape({}).isRequired, }).isRequired, communityName: PT.string, @@ -216,6 +219,7 @@ ChallengeListingPageContainer.propTypes = { getMarathonMatches: PT.func.isRequired, markHeaderMenu: PT.func.isRequired, setFilter: PT.func.isRequired, + setFilterState: PT.func.isRequired, /* OLD PROPS BELOW */ listingOnly: PT.bool, @@ -318,6 +322,7 @@ function mapDispatchToProps(dispatch) { getMarathonMatches: (...rest) => getMarathonMatches(dispatch, ...rest), reset: () => dispatch(a.reset()), setFilter: f => dispatch(a.setFilter(f)), + setFilterState: state => dispatch(a.setFilterState(state)), markHeaderMenu: () => dispatch(ah.setCurrentNav('Compete', 'All Challenges')), }; diff --git a/src/shared/reducers/challenge-listing.js b/src/shared/reducers/challenge-listing.js index 387d2abd6d..de92a5b544 100644 --- a/src/shared/reducers/challenge-listing.js +++ b/src/shared/reducers/challenge-listing.js @@ -36,7 +36,7 @@ import logger from 'utils/logger'; import SideBarFilter from 'components/challenge-listing/SideBarFilters/SideBarFilter'; import { handleActions } from 'redux-actions'; -import { COMMUNITY } from 'utils/tc'; +import { COMPETITION_TRACKS } from 'utils/tc'; /** * Normalizes a regular challenge object received from the backend. @@ -58,7 +58,7 @@ export function normalizeChallenge(challenge) { }); } return _.defaults(_.clone(challenge), { - communities: new Set([COMMUNITY[challenge.track]]), + communities: new Set([COMPETITION_TRACKS[challenge.track]]), groups, platforms: '', registrationOpen, @@ -95,7 +95,7 @@ export function normalizeMarathonMatch(challenge) { challengeType: 'Marathon', allPhases: allphases, currentPhases: allphases.filter(phase => phase.phaseStatus === 'Open'), - communities: new Set([COMMUNITY.DATA_SCIENCE]), + communities: new Set([COMPETITION_TRACKS.DATA_SCIENCE]), currentPhaseName: endTimestamp > Date.now() ? 'Registration' : '', groups, numRegistrants: challenge.numRegistrants ? challenge.numRegistrants[0] : 0, @@ -293,12 +293,16 @@ function create(initialState) { [a.getMarathonMatches]: onGetMarathonMatches, [a.reset]: onReset, [a.setFilter]: (state, { payload }) => ({ ...state, filter: payload }), + [a.setFilterState]: (state, { payload }) => ({ + ...state, filterState: payload, + }), }, _.defaults(_.clone(initialState) || {}, { challenges: [], challengeSubtracks: [], challengeTags: [], counts: {}, filter: (new SideBarFilter()).getURLEncoded(), + filterState: {}, oldestData: Date.now(), pendingRequests: {}, })); diff --git a/src/shared/utils/challenge-listing/filter.js b/src/shared/utils/challenge-listing/filter.js new file mode 100644 index 0000000000..2453becd3d --- /dev/null +++ b/src/shared/utils/challenge-listing/filter.js @@ -0,0 +1,104 @@ +/** + * Universal challenge filter. Must be used in all places where we need filter + * or fetch challenges. This way we keep all related logic in the same place, + * which simplifies maintenance and modifications of the code. + * + * The state of challenge filter is a plain JS object, containing only plain + * data fields. It allows to avoid any problems with its storage inside Redux + * store; with its serialization into / deserialization from a string. Each + * field of the state describes a single rule for filtering the challenges. + * The filter allows only those challenges that match all defined rules. + * Undefined, null fields are ignored. + * + * The following fields are supported: + * + * endDate {Number|String} - Permits only those challenges with submission + * deadline before this date. + * + * groupIds {Object} - Permits only the challenges belonging to at least one + * of the groups which IDs are presented as keys in this object. + * + * registrationEnd {Number|String} - Permits only the challenges with + * registration deadline before this date. + * + * startDate {Number|String} - Permits only those challenges started after this + * date. + * + * status {Object} - Permits only the challenges with status matching one of + * the keys of this object. + * + * subtracks {Object} - Permits only the challenges belonging to at least one + * of the competition subtracks presented as keys of this object. + * + * text {String} - Free-text string which will be matched against challenge + * name, its platform and technology tags. If not found anywhere, the challenge + * is filtered out. Case-insensitive. + * + * tracks {Object} - Permits only the challenges belonging to at least one of + * the competition tracks presented as keys of this object. + * + * userIds {Object} - Permits only the challenges where the specified users are + * participating. + */ + +import _ from 'lodash'; +import { COMPETITION_TRACKS } from 'utils/tc'; + +function filterByTrack(challenge, state) { + if (!state.tracks) return true; + return _.keys(state.tracks).some(track => challenge.communities.has(track)); +} + +/** + * Returns clone of the state with the specified competition track added. + * @param {Object} state + * @param {String} track + * @return {Object} Resulting state. + */ +export function addTrack(state, track) { + /* When state has no tracks field all tracks are allowed, thus no need to + * touch the object. */ + if (!state.tracks) return state; + + const res = _.clone(state); + res.tracks = _.clone(res.tracks); + res.tracks[track] = true; + + /* Selecting all tracks is the same as having no tracks field. To keep the + * state more simple at any time, we remove tracks field in such case. */ + if (!_.values(COMPETITION_TRACKS).some(item => !res.tracks[item])) { + delete res.tracks; + } + + return res; +} + +/** + * Generates filter function for the state. + * @param {Object} state + * @return {Function} + */ +export function getFilterFunction(state) { + return item => filterByTrack(item, state); +} + +/** + * Returns clone of the state with the specified competition track removed. + * @param {Object} state + * @param {String} track + * @return {Object} Resulting state. + */ +export function removeTrack(state, track) { + const res = _.clone(state); + if (res.tracks) res.tracks = _.clone(res.tracks); + else { + res.tracks = {}; + _.forIn(COMPETITION_TRACKS, (item) => { + res.tracks[item] = true; + }); + } + delete res.tracks[track]; + return res; +} + +export default undefined; diff --git a/src/shared/utils/tc.js b/src/shared/utils/tc.js index 47ae7ecf21..52fe576a91 100644 --- a/src/shared/utils/tc.js +++ b/src/shared/utils/tc.js @@ -5,7 +5,7 @@ /** * Codes of the Topcoder communities. */ -export const COMMUNITY = { +export const COMPETITION_TRACKS = { DATA_SCIENCE: 'datasci', DESIGN: 'design', DEVELOP: 'develop', From 7cba89b7b22d476eb288e5b576f849d9ab1e5ed2 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 05:27:15 +0200 Subject: [PATCH 03/20] Text search bar is rewired to Redux and new filter implementation --- src/shared/actions/challenge-listing.js | 6 ++ .../Filters/ChallengeFilters.jsx | 16 ++-- .../Filters/ChallengeSearchBar/index.jsx | 77 +++++++------------ .../components/challenge-listing/index.jsx | 4 + .../containers/ChallengeListing/index.jsx | 5 ++ src/shared/reducers/challenge-listing.js | 4 + src/shared/utils/challenge-listing/filter.js | 41 +++++++++- 7 files changed, 91 insertions(+), 62 deletions(-) diff --git a/src/shared/actions/challenge-listing.js b/src/shared/actions/challenge-listing.js index e9dcfa46a1..9bc6238d8f 100644 --- a/src/shared/actions/challenge-listing.js +++ b/src/shared/actions/challenge-listing.js @@ -242,5 +242,11 @@ export default createActions({ RESET: reset, SET_FILTER: setFilter, SET_FILTER_STATE: _.identity, + + /* Corresponding action sets the challenge search text without applying + * it to the challenge filter. It is necessary because current version of + * the UI suggests that to execute the search user must press the search + * button. */ + SET_SEARCH_TEXT: _.identity, }, }); diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 151afd4a9e..1b02bf1ebf 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -65,12 +65,6 @@ class ChallengeFilters extends React.Component { showFilters: false, showEditTrackPanel: false, }; - this.searchBarProps = { - placeholder: 'Search Challenges', - }; - if (props.searchQuery) { - this.searchBarProps.query = props.searchQuery; - } } componentWillReceiveProps(nextProps) { @@ -176,8 +170,10 @@ class ChallengeFilters extends React.Component { setCardType={this.props.setCardType} /> this.onSearch(str)} - {...this.searchBarProps} + onSearch={text => setFilterState(Filter.setText(filterState, text))} + placeholder="Search Challenges" + query={this.props.searchText} + setQuery={this.props.setSearchText} /> { this.props.isCardTypeSet === 'Challenges' ? @@ -288,7 +284,6 @@ ChallengeFilters.defaultProps = { communityName: null, filter: new ChallengeFilter(), isCardTypeSet: '', - searchQuery: '', onFilter: _.noop, onSaveFilter: _.noop, onSearch: _.noop, @@ -301,12 +296,13 @@ ChallengeFilters.propTypes = { filter: PT.instanceOf(ChallengeFilter), filterState: PT.shape().isRequired, isCardTypeSet: PT.string, - searchQuery: PT.string, onFilter: PT.func, onSearch: PT.func, onSaveFilter: PT.func, setCardType: PT.func, setFilterState: PT.func.isRequired, + searchText: PT.string.isRequired, + setSearchText: PT.func.isRequired, validKeywords: PT.arrayOf(TagShape).isRequired, validSubtracks: PT.arrayOf(TagShape).isRequired, }; diff --git a/src/shared/components/challenge-listing/Filters/ChallengeSearchBar/index.jsx b/src/shared/components/challenge-listing/Filters/ChallengeSearchBar/index.jsx index 93bed5c96d..e4c68aaaf8 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeSearchBar/index.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeSearchBar/index.jsx @@ -17,63 +17,38 @@ import PT from 'prop-types'; import './style.scss'; import ZoomIcon from './ui-zoom.svg'; -class ChallengeSearchBar extends React.Component { - - constructor(props) { - super(props); - this.state = { - value: '', - }; - if (this.props.query) { - this.state.value = this.props.query; - this.onSearch(); - } - } - - onKeyPress(event) { - switch (event.key) { - case 'Enter': - return this.onSearch(); - default: - return null; - } - } - - onSearch() { - if (this.props.onSearch) this.props.onSearch(this.state.value); - } - - render() { - return ( -
- this.setState({ value: event.target.value })} - onKeyPress={event => this.onKeyPress(event)} - placeholder={this.props.placeholder} - type="text" - value={this.state.value} - /> - this.onSearch()} - > - - -
- ); - } +export default function ChallengeSearchBar({ + onSearch, + placeholder, + query, + setQuery, +}) { + return ( +
+ setQuery(event.target.value)} + onKeyPress={event => (event.key === 'Enter' ? onSearch(query) : null)} + placeholder={placeholder} + type="text" + value={query} + /> + onSearch(query)} + > + + +
+ ); } ChallengeSearchBar.defaultProps = { - onSearch: () => true, placeholder: '', - query: '', }; ChallengeSearchBar.propTypes = { - onSearch: PT.func, + onSearch: PT.func.isRequired, placeholder: PT.string, - query: PT.string, + query: PT.string.isRequired, + setQuery: PT.func.isRequired, }; - -export default ChallengeSearchBar; diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index ca6459a9fa..c668d08fbd 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -353,6 +353,8 @@ class ChallengeFiltersExample extends React.Component { validSubtracks={this.props.challengeSubtracks.map(keywordsMapper)} setCardType={_.noop/* cardType => this.setCardType(cardType) */} setFilterState={this.props.setFilterState} + searchText={this.props.searchText} + setSearchText={this.props.setSearchText} isCardTypeSet={'Challenges' /* this.state.currentCardType */} ref={(node) => { this.challengeFilters = node; }} /> @@ -457,6 +459,8 @@ ChallengeFiltersExample.propTypes = { loadingChallenges: PT.bool.isRequired, setFilter: PT.func.isRequired, setFilterState: PT.func.isRequired, + searchText: PT.string.isRequired, + setSearchText: PT.func.isRequired, /* OLD PROPS BELOW */ config: PT.shape({ diff --git a/src/shared/containers/ChallengeListing/index.jsx b/src/shared/containers/ChallengeListing/index.jsx index 4954ef1edc..350e47c384 100644 --- a/src/shared/containers/ChallengeListing/index.jsx +++ b/src/shared/containers/ChallengeListing/index.jsx @@ -175,6 +175,8 @@ class ChallengeListingPageContainer extends React.Component { } }} setFilterState={this.props.setFilterState} + searchText={this.props.challengeListing.searchText} + setSearchText={this.props.setSearchText} /* OLD PROPS BELOW */ challengeGroupId={challengeGroupId} @@ -209,6 +211,7 @@ ChallengeListingPageContainer.propTypes = { filter: PT.string.isRequired, filterState: PT.shape.isRequired, pendingRequests: PT.shape({}).isRequired, + searchText: PT.string.isRequired, }).isRequired, communityName: PT.string, getAllChallenges: PT.func.isRequired, @@ -220,6 +223,7 @@ ChallengeListingPageContainer.propTypes = { markHeaderMenu: PT.func.isRequired, setFilter: PT.func.isRequired, setFilterState: PT.func.isRequired, + setSearchText: PT.func.isRequired, /* OLD PROPS BELOW */ listingOnly: PT.bool, @@ -325,6 +329,7 @@ function mapDispatchToProps(dispatch) { setFilterState: state => dispatch(a.setFilterState(state)), markHeaderMenu: () => dispatch(ah.setCurrentNav('Compete', 'All Challenges')), + setSearchText: text => dispatch(a.setSearchText(text)), }; } diff --git a/src/shared/reducers/challenge-listing.js b/src/shared/reducers/challenge-listing.js index de92a5b544..112acf02ee 100644 --- a/src/shared/reducers/challenge-listing.js +++ b/src/shared/reducers/challenge-listing.js @@ -296,6 +296,9 @@ function create(initialState) { [a.setFilterState]: (state, { payload }) => ({ ...state, filterState: payload, }), + [a.setSearchText]: (state, { payload }) => ({ + ...state, searchText: payload, + }), }, _.defaults(_.clone(initialState) || {}, { challenges: [], challengeSubtracks: [], @@ -305,6 +308,7 @@ function create(initialState) { filterState: {}, oldestData: Date.now(), pendingRequests: {}, + searchText: '', })); } diff --git a/src/shared/utils/challenge-listing/filter.js b/src/shared/utils/challenge-listing/filter.js index 2453becd3d..6d071a393f 100644 --- a/src/shared/utils/challenge-listing/filter.js +++ b/src/shared/utils/challenge-listing/filter.js @@ -44,6 +44,28 @@ import _ from 'lodash'; import { COMPETITION_TRACKS } from 'utils/tc'; +/** + * Returns true if the challenge satisfies the free-text filtering condition set + * in the provided filter state. + * @param {Object} challenge + * @param {Object} state + * @return {Boolean} + */ +function filterByText(challenge, state) { + if (!state.text) return true; + const str = + `${challenge.name} ${challenge.platforms} ${challenge.technologies}` + .toLowerCase(); + return str.indexOf(state.text.toLowerCase()) >= 0; +} + +/** + * Returns true if the challenge satisfies the track filtering condition set in + * the provided filter state. + * @param {Object} challenge + * @param {Object} state + * @return {Boolean} + */ function filterByTrack(challenge, state) { if (!state.tracks) return true; return _.keys(state.tracks).some(track => challenge.communities.has(track)); @@ -79,7 +101,8 @@ export function addTrack(state, track) { * @return {Function} */ export function getFilterFunction(state) { - return item => filterByTrack(item, state); + return challenge => filterByTrack(challenge, state) + && filterByText(challenge, state); } /** @@ -101,4 +124,20 @@ export function removeTrack(state, track) { return res; } +/** + * Clones fitler state and sets the free-text string for the filtering by + * challenge name and tags. To clear the string set it to anything evaluating + * to falst (empty string, null, undefined). + * @param {Object} state + * @param {String} text + * @return {Object} Resulting string. + */ +export function setText(state, text) { + if (!text && !state.text) return state; + const res = _.clone(state); + if (text) res.text = text; + else delete res.text; + return res; +} + export default undefined; From f2186349fb38e564d3263e9d7575d53ed6aece3c Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 06:13:10 +0200 Subject: [PATCH 04/20] Adds separate actions / container / reducer for the header filter panel --- src/shared/actions/challenge-listing.js | 6 ---- .../actions/challenge-listing/filter-panel.js | 17 +++++++++++ .../components/challenge-listing/index.jsx | 8 ++---- .../containers/ChallengeListing/index.jsx | 5 ---- .../challenge-listing/FilterPanel.js | 22 +++++++++++++++ src/shared/reducers/challenge-listing.js | 10 +++---- .../challenge-listing/filter-panel.js | 28 +++++++++++++++++++ 7 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 src/shared/actions/challenge-listing/filter-panel.js create mode 100644 src/shared/containers/challenge-listing/FilterPanel.js create mode 100644 src/shared/reducers/challenge-listing/filter-panel.js diff --git a/src/shared/actions/challenge-listing.js b/src/shared/actions/challenge-listing.js index 9bc6238d8f..e9dcfa46a1 100644 --- a/src/shared/actions/challenge-listing.js +++ b/src/shared/actions/challenge-listing.js @@ -242,11 +242,5 @@ export default createActions({ RESET: reset, SET_FILTER: setFilter, SET_FILTER_STATE: _.identity, - - /* Corresponding action sets the challenge search text without applying - * it to the challenge filter. It is necessary because current version of - * the UI suggests that to execute the search user must press the search - * button. */ - SET_SEARCH_TEXT: _.identity, }, }); diff --git a/src/shared/actions/challenge-listing/filter-panel.js b/src/shared/actions/challenge-listing/filter-panel.js new file mode 100644 index 0000000000..2bb37ffac9 --- /dev/null +++ b/src/shared/actions/challenge-listing/filter-panel.js @@ -0,0 +1,17 @@ +/** + * Actions related to the header filter panel. + */ + +import _ from 'lodash'; +import { createActions } from 'redux-actions'; + +export default createActions({ + CHALLENGE_LISTING: { + FILTER_PANEL: { + /* Updates text in the search bar, without applying it to the active + * challenge filter. The text will be set to the filter when Enter is + * pressed. */ + SET_SEARCH_TEXT: _.identity, + }, + }, +}); diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index c668d08fbd..894a805bd6 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -14,14 +14,14 @@ */ import _ from 'lodash'; +import ChallengeFilters from 'containers/challenge-listing/FilterPanel'; import React from 'react'; import PT from 'prop-types'; import config from 'utils/config'; import Sticky from 'react-stickynode'; import * as Filter from 'utils/challenge-listing/filter'; - import ChallengeFilterWithSearch from './Filters/ChallengeFilterWithSearch'; -import ChallengeFilters from './Filters/ChallengeFilters'; + import SideBarFilter, { MODE as SideBarFilterModes } from './SideBarFilters/SideBarFilter'; import SideBarFilters from './SideBarFilters'; import ChallengeCard from './ChallengeCard'; @@ -353,8 +353,6 @@ class ChallengeFiltersExample extends React.Component { validSubtracks={this.props.challengeSubtracks.map(keywordsMapper)} setCardType={_.noop/* cardType => this.setCardType(cardType) */} setFilterState={this.props.setFilterState} - searchText={this.props.searchText} - setSearchText={this.props.setSearchText} isCardTypeSet={'Challenges' /* this.state.currentCardType */} ref={(node) => { this.challengeFilters = node; }} /> @@ -459,8 +457,6 @@ ChallengeFiltersExample.propTypes = { loadingChallenges: PT.bool.isRequired, setFilter: PT.func.isRequired, setFilterState: PT.func.isRequired, - searchText: PT.string.isRequired, - setSearchText: PT.func.isRequired, /* OLD PROPS BELOW */ config: PT.shape({ diff --git a/src/shared/containers/ChallengeListing/index.jsx b/src/shared/containers/ChallengeListing/index.jsx index 350e47c384..4954ef1edc 100644 --- a/src/shared/containers/ChallengeListing/index.jsx +++ b/src/shared/containers/ChallengeListing/index.jsx @@ -175,8 +175,6 @@ class ChallengeListingPageContainer extends React.Component { } }} setFilterState={this.props.setFilterState} - searchText={this.props.challengeListing.searchText} - setSearchText={this.props.setSearchText} /* OLD PROPS BELOW */ challengeGroupId={challengeGroupId} @@ -211,7 +209,6 @@ ChallengeListingPageContainer.propTypes = { filter: PT.string.isRequired, filterState: PT.shape.isRequired, pendingRequests: PT.shape({}).isRequired, - searchText: PT.string.isRequired, }).isRequired, communityName: PT.string, getAllChallenges: PT.func.isRequired, @@ -223,7 +220,6 @@ ChallengeListingPageContainer.propTypes = { markHeaderMenu: PT.func.isRequired, setFilter: PT.func.isRequired, setFilterState: PT.func.isRequired, - setSearchText: PT.func.isRequired, /* OLD PROPS BELOW */ listingOnly: PT.bool, @@ -329,7 +325,6 @@ function mapDispatchToProps(dispatch) { setFilterState: state => dispatch(a.setFilterState(state)), markHeaderMenu: () => dispatch(ah.setCurrentNav('Compete', 'All Challenges')), - setSearchText: text => dispatch(a.setSearchText(text)), }; } diff --git a/src/shared/containers/challenge-listing/FilterPanel.js b/src/shared/containers/challenge-listing/FilterPanel.js new file mode 100644 index 0000000000..6ed2486cd2 --- /dev/null +++ b/src/shared/containers/challenge-listing/FilterPanel.js @@ -0,0 +1,22 @@ +/** + * Container for the header filters panel. + */ + +import actions from 'actions/challenge-listing/filter-panel'; +import FilterPanel from 'components/challenge-listing/Filters/ChallengeFilters'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +function mapDispatchToProps(dispatch) { + const a = actions.challengeListing.filterPanel; + return bindActionCreators(a, dispatch); +} + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + ...state.challengeListing.filterPanel, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(FilterPanel); diff --git a/src/shared/reducers/challenge-listing.js b/src/shared/reducers/challenge-listing.js index 112acf02ee..5ad5b7d6f3 100644 --- a/src/shared/reducers/challenge-listing.js +++ b/src/shared/reducers/challenge-listing.js @@ -37,6 +37,8 @@ import SideBarFilter from 'components/challenge-listing/SideBarFilters/SideBarFilter'; import { handleActions } from 'redux-actions'; import { COMPETITION_TRACKS } from 'utils/tc'; +import { combine } from 'utils/redux'; +import filterPanel from './challenge-listing/filter-panel'; /** * Normalizes a regular challenge object received from the backend. @@ -296,9 +298,6 @@ function create(initialState) { [a.setFilterState]: (state, { payload }) => ({ ...state, filterState: payload, }), - [a.setSearchText]: (state, { payload }) => ({ - ...state, searchText: payload, - }), }, _.defaults(_.clone(initialState) || {}, { challenges: [], challengeSubtracks: [], @@ -308,7 +307,6 @@ function create(initialState) { filterState: {}, oldestData: Date.now(), pendingRequests: {}, - searchText: '', })); } @@ -322,8 +320,8 @@ function create(initialState) { export function factory() { /* Server-side rendering is not implemented yet. Let's first ensure it all works fine without it. */ - return Promise.resolve(create()); + return Promise.resolve(combine(create(), { filterPanel })); } /* Default reducer with empty initial state. */ -export default create(); +export default combine(create(), { filterPanel }); diff --git a/src/shared/reducers/challenge-listing/filter-panel.js b/src/shared/reducers/challenge-listing/filter-panel.js new file mode 100644 index 0000000000..9a68c76959 --- /dev/null +++ b/src/shared/reducers/challenge-listing/filter-panel.js @@ -0,0 +1,28 @@ +/** + * Reducer for actions related to the header filter panel. + */ + +import _ from 'lodash'; +import actions from 'actions/challenge-listing/filter-panel'; +import { handleActions } from 'redux-actions'; + +/** + * Creates a new reducer. + * @param {Object} initialState Optional. Initial state. + * @return {Function} Reducer. + */ +function create(initialState = {}) { + const a = actions.challengeListing.filterPanel; + return handleActions({ + [a.setSearchText]: (state, { payload }) => ({ + ...state, searchText: payload }), + }, _.defaults(initialState, { + searchText: '', + })); +} + +export function factory() { + return Promise.resolve(create()); +} + +export default create(); From 7aa3036678c44e79dd96796134f808234ece3525 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 12:09:10 +0200 Subject: [PATCH 05/20] Expantion of filter panel and tracks modal (mobile) is wired to Redux --- .../actions/challenge-listing/filter-panel.js | 6 + .../Filters/ChallengeFilters.jsx | 181 ++++++------------ .../components/challenge-listing/index.jsx | 5 +- .../challenge-listing/FilterPanel.js | 8 +- .../challenge-listing/filter-panel.js | 6 + 5 files changed, 75 insertions(+), 131 deletions(-) diff --git a/src/shared/actions/challenge-listing/filter-panel.js b/src/shared/actions/challenge-listing/filter-panel.js index 2bb37ffac9..66de23b494 100644 --- a/src/shared/actions/challenge-listing/filter-panel.js +++ b/src/shared/actions/challenge-listing/filter-panel.js @@ -8,10 +8,16 @@ import { createActions } from 'redux-actions'; export default createActions({ CHALLENGE_LISTING: { FILTER_PANEL: { + /* Expands / collapses the filter panel. */ + SET_EXPANDED: _.identity, + /* Updates text in the search bar, without applying it to the active * challenge filter. The text will be set to the filter when Enter is * pressed. */ SET_SEARCH_TEXT: _.identity, + + /* Shows / hides the modal with track switches (for mobile view only). */ + SHOW_TRACK_MODAL: _.identity, }, }, }); diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 1b02bf1ebf..4f2f76bf0b 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -1,41 +1,5 @@ -/* eslint jsx-a11y/no-static-element-interactions:0 */ - /** * Challenge search & filters panel. - * - * It consists of the always visible search panel and of the filters pannel, - * which can be hidden/shown by the dedicated switch in the search panel. - * - * Thus the search panel contains: - * - Search string input field & search button; - * - Data Science / Design / Development switches; - * - Filters panel hide/show switch. - * - * For the content of filters panel look into docs of the FiltersPanel component. - * - * This component accepts two optional callbacks via the 'onFilter' and 'onSearch' - * properties. - * - * When provided, the 'onFilter' callback is triggered each time the user changes - * any filter. An auxiliary filter function is passed in as the first argument. - * That function can be passed into the .filter() method of challenge objects - * array to filter it according to the current set of filters. - * - * When provided, the 'onSearch' callback is triggered each time the user presses - * Enter inside the search input field, or clicks the search button next to that - * field. The search&filter query string is passed as the first argument into - * this callback. This query string can be appended to a call to V2 TopCoder API - * to perform the search. IMPORTANT: As it seems that V2 API is not really compatible - * with the search and filtering demanded, in the current implementation an empty - * string is passed into the first argument of this callback, and the next three - * arguments are used to pass in: - * - The search string; - * - The set of Data Science / Design / Development switch values, - * which is a JS set of DATA_SCIENCE_TRACK, DESIGN_TRACK, and DEVELOP_TRACK - * constants; - * - The filter function. - * Using this data we can use existing V2 API to fetch challenges from the - * Design and Development tracks, and then filter them on the front-end side. */ import _ from 'lodash'; @@ -45,7 +9,7 @@ import SwitchWithLabel from 'components/SwitchWithLabel'; import * as Filter from 'utils/challenge-listing/filter'; import { COMPETITION_TRACKS as TRACKS } from 'utils/tc'; -import ChallengeFilter, { DATA_SCIENCE_TRACK, DESIGN_TRACK, DEVELOP_TRACK } from './ChallengeFilter'; +import ChallengeFilter from './ChallengeFilter'; import ChallengeSearchBar from './ChallengeSearchBar'; import EditTrackPanel from './EditTrackPanel'; import FiltersIcon from './FiltersSwitch/FiltersIcon'; @@ -62,8 +26,6 @@ class ChallengeFilters extends React.Component { this.state = { filter: props.filter, filtersCount: props.filter.count(), - showFilters: false, - showEditTrackPanel: false, }; } @@ -105,26 +67,6 @@ class ChallengeFilters extends React.Component { this.props.onFilter(f); } - /** - * Triggers the 'onSearch' callback provided by the parent component, if any. - * - * The challenge query string for V2 API is passed into the callback as the - * first argument. As V2 API does not really support the intended searching - * and filtering, at the moment an empty string is always passed into the - * first argument, and all search & filtering data are passed into the next - * three arguments: - * - The search string; - * - The set of values of Data Science / Design / Development track switches - * (JS Set of DATA_SCIENCE_TRACK, DESIGN_TRACK, DEVELOP_TRACK constants); - * - The filter function. - * - * @param {String} searchString - */ - onSearch(searchString) { - if (!this.props.onSearch) return; - this.props.onSearch(searchString, this.state.filter); - } - /** * Sets the keywords filter in the FilterPanel to the specified value. * @param {String} keywords A comma-separated list of the keywords. @@ -133,35 +75,24 @@ class ChallengeFilters extends React.Component { if (this.filtersPanel) this.filtersPanel.onKeywordsChanged([keywords]); } - /** - * Sets/unsets the specified track in the this.tracks set. - * @param {String} community One of DATA_SCIENCE_TRACK, DESIGN_TRACK, DEVELOP_TRACK. - * @param {Boolean} set True to include the track into the set, false to remove it. - */ - setTracks(track, set) { - const filter = new ChallengeFilter(this.state.filter); - if (set) filter.tracks.add(track); - else filter.tracks.delete(track); - this.props.onFilter(filter); - this.setState({ filter }); - } - - /** - * Hide/Show the EditTrackPanel - */ - toggleEditTrackPanel() { - this.setState({ showEditTrackPanel: !this.state.showEditTrackPanel }); - } - - /** - * Hide/Show the filters - */ - toggleShowFilters() { - this.setState({ showFilters: !this.state.showFilters }); - } - render() { - const { filterState, setFilterState } = this.props; + const { + expanded, + filterState, + setExpanded, + setFilterState, + showTrackModal, + trackModalShown, + } = this.props; + + const isTrackOn = track => + !filterState.tracks || Boolean(filterState.tracks[track]); + + const switchTrack = (track, on) => { + const act = on ? Filter.addTrack : Filter.removeTrack; + setFilterState(act(filterState, track)); + }; + return (
@@ -181,41 +112,23 @@ class ChallengeFilters extends React.Component { { - const act = on ? Filter.addTrack : Filter.removeTrack; - setFilterState(act(filterState, TRACKS.DESIGN)); - }} + onSwitch={on => switchTrack(TRACKS.DESIGN, on)} /> { - const act = on ? Filter.addTrack : Filter.removeTrack; - setFilterState(act(filterState, TRACKS.DEVELOP)); - }} + onSwitch={on => switchTrack(TRACKS.DEVELOP, on)} /> { - const act = on ? Filter.addTrack : Filter.removeTrack; - setFilterState(act(filterState, TRACKS.DATA_SCIENCE)); - }} + onSwitch={on => switchTrack(TRACKS.DATA_SCIENCE, on)} /> @@ -225,33 +138,45 @@ class ChallengeFilters extends React.Component { { this.props.isCardTypeSet === 'Challenges' ? ( - this.toggleEditTrackPanel()} styleName="track-btn"> + showTrackModal(true)} + role="button" + styleName="track-btn" + tabIndex={0} + > Tracks ) : '' } + {/* TODO: Two components below are filter switch buttons for + * mobile and desktop views. Should be refactored to use the + * same component, which automatically changes its style depending + * on the viewport size. */} this.toggleShowFilters()} + onClick={() => setExpanded(!expanded)} + role="button" styleName="filter-btn" + tabIndex={0} > Filter this.setState({ showFilters: active })} + onSwitch={setExpanded} styleName="FiltersSwitch" />
+
); @@ -286,23 +211,25 @@ ChallengeFilters.defaultProps = { isCardTypeSet: '', onFilter: _.noop, onSaveFilter: _.noop, - onSearch: _.noop, setCardType: _.noop, }; ChallengeFilters.propTypes = { challengeGroupId: PT.string.isRequired, communityName: PT.string, + expanded: PT.bool.isRequired, filter: PT.instanceOf(ChallengeFilter), filterState: PT.shape().isRequired, isCardTypeSet: PT.string, onFilter: PT.func, - onSearch: PT.func, onSaveFilter: PT.func, setCardType: PT.func, + setExpanded: PT.func.isRequired, setFilterState: PT.func.isRequired, searchText: PT.string.isRequired, setSearchText: PT.func.isRequired, + showTrackModal: PT.func.isRequired, + trackModalShown: PT.bool.isRequired, validKeywords: PT.arrayOf(TagShape).isRequired, validSubtracks: PT.arrayOf(TagShape).isRequired, }; diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index 894a805bd6..774e751597 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -336,7 +336,7 @@ class ChallengeFiltersExample extends React.Component {
this.onFilterByTopFilter(topFilter)} onSaveFilter={(filterToSave) => { if (this.sidebar) { @@ -352,7 +352,7 @@ class ChallengeFiltersExample extends React.Component { validKeywords={this.props.challengeTags.map(keywordsMapper)} validSubtracks={this.props.challengeSubtracks.map(keywordsMapper)} setCardType={_.noop/* cardType => this.setCardType(cardType) */} - setFilterState={this.props.setFilterState} + isCardTypeSet={'Challenges' /* this.state.currentCardType */} ref={(node) => { this.challengeFilters = node; }} /> @@ -456,7 +456,6 @@ ChallengeFiltersExample.propTypes = { getMarathonMatches: PT.func.isRequired, loadingChallenges: PT.bool.isRequired, setFilter: PT.func.isRequired, - setFilterState: PT.func.isRequired, /* OLD PROPS BELOW */ config: PT.shape({ diff --git a/src/shared/containers/challenge-listing/FilterPanel.js b/src/shared/containers/challenge-listing/FilterPanel.js index 6ed2486cd2..ec24599c97 100644 --- a/src/shared/containers/challenge-listing/FilterPanel.js +++ b/src/shared/containers/challenge-listing/FilterPanel.js @@ -3,19 +3,25 @@ */ import actions from 'actions/challenge-listing/filter-panel'; +import challengeListingActions from 'actions/challenge-listing'; import FilterPanel from 'components/challenge-listing/Filters/ChallengeFilters'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; function mapDispatchToProps(dispatch) { const a = actions.challengeListing.filterPanel; - return bindActionCreators(a, dispatch); + const cla = challengeListingActions.challengeListing; + return { + ...bindActionCreators(a, dispatch), + setFilterState: s => dispatch(cla.setFilterState(s)), + }; } function mapStateToProps(state, ownProps) { return { ...ownProps, ...state.challengeListing.filterPanel, + filterState: state.challengeListing.filterState, }; } diff --git a/src/shared/reducers/challenge-listing/filter-panel.js b/src/shared/reducers/challenge-listing/filter-panel.js index 9a68c76959..9341007c13 100644 --- a/src/shared/reducers/challenge-listing/filter-panel.js +++ b/src/shared/reducers/challenge-listing/filter-panel.js @@ -14,10 +14,16 @@ import { handleActions } from 'redux-actions'; function create(initialState = {}) { const a = actions.challengeListing.filterPanel; return handleActions({ + [a.setExpanded]: (state, { payload }) => ({ + ...state, expanded: payload }), [a.setSearchText]: (state, { payload }) => ({ ...state, searchText: payload }), + [a.showTrackModal]: (state, { payload }) => ({ + ...state, trackModalShown: payload }), }, _.defaults(initialState, { + expanded: false, searchText: '', + trackModalShown: false, })); } From a859bedd42de301fcd94b203d4f373edf0235077 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 14:13:02 +0200 Subject: [PATCH 06/20] Loading of challenge subtracks and tags moved to FilterPanel container --- .../Filters/ChallengeFilters.jsx | 9 +-- .../Filters/FiltersPanel/index.jsx | 22 +++---- .../components/challenge-listing/index.jsx | 47 +------------- .../containers/ChallengeListing/index.jsx | 15 ----- .../challenge-listing/FilterPanel.js | 28 --------- .../challenge-listing/FilterPanel.jsx | 62 +++++++++++++++++++ src/shared/reducers/challenge-listing.js | 2 + 7 files changed, 78 insertions(+), 107 deletions(-) delete mode 100644 src/shared/containers/challenge-listing/FilterPanel.js create mode 100644 src/shared/containers/challenge-listing/FilterPanel.jsx diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 4f2f76bf0b..54e9c0bc5d 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -200,11 +200,6 @@ class ChallengeFilters extends React.Component { } } -const TagShape = PT.shape({ - label: PT.string.isRequired, - value: PT.string.isRequired, -}); - ChallengeFilters.defaultProps = { communityName: null, filter: new ChallengeFilter(), @@ -230,8 +225,8 @@ ChallengeFilters.propTypes = { setSearchText: PT.func.isRequired, showTrackModal: PT.func.isRequired, trackModalShown: PT.bool.isRequired, - validKeywords: PT.arrayOf(TagShape).isRequired, - validSubtracks: PT.arrayOf(TagShape).isRequired, + validKeywords: PT.arrayOf(PT.string).isRequired, + validSubtracks: PT.arrayOf(PT.string).isRequired, }; export default ChallengeFilters; diff --git a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx index 50c86747dc..bec654c78e 100644 --- a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx +++ b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx @@ -101,6 +101,11 @@ class FiltersPanel extends React.Component { } render() { + const { + validKeywords, + validSubtracks, + } = this.props; + let className = 'FiltersPanel'; if (this.props.hidden) className += ' hidden'; @@ -115,6 +120,8 @@ class FiltersPanel extends React.Component { }]; } + const mapOps = item => ({ label: item, value: item }); + return (
@@ -131,7 +138,7 @@ class FiltersPanel extends React.Component { id="keyword-select" multi onChange={value => this.onKeywordsChanged(value ? value.split(',') : [])} - options={this.props.validKeywords} + options={validKeywords.map(mapOps)} simpleValue value={this.state.filter.keywords.join(',')} /> @@ -163,7 +170,7 @@ class FiltersPanel extends React.Component { id="track-select" multi onChange={value => this.onSubtracksChanged(value ? value.split(',') : [])} - options={this.props.validSubtracks} + options={validSubtracks.map(mapOps)} simpleValue value={this.state.filter.subtracks.join(',')} /> @@ -210,13 +217,6 @@ FiltersPanel.defaultProps = { onClose: _.noop, }; -const SelectOptions = PT.arrayOf( - PT.shape({ - label: PT.string.isRequired, - value: PT.string.isRequired, - }), -); - FiltersPanel.propTypes = { challengeGroupId: PT.string.isRequired, communityName: PT.string, @@ -225,8 +225,8 @@ FiltersPanel.propTypes = { onClearFilters: PT.func, onFilter: PT.func, onSaveFilter: PT.func, - validKeywords: SelectOptions.isRequired, - validSubtracks: SelectOptions.isRequired, + validKeywords: PT.arrayOf(PT.string).isRequired, + validSubtracks: PT.arrayOf(PT.string).isRequired, onClose: PT.func, }; diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index 774e751597..d99d42458e 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -33,18 +33,6 @@ import ChallengesSidebar from './Sidebar'; import './style.scss'; -/** - * Helper function for generation of VALID_KEYWORDS and VALID_TRACKS arrays. - * @param {String} keyword - * @return {Object} The valid object to include into the array which will be - * passed into the ChallengeFilters component. - */ -function keywordsMapper(keyword) { - return { - label: keyword, - value: keyword, - }; -} // Number of challenge placeholder card to display const CHALLENGE_PLACEHOLDER_COUNT = 8; @@ -131,27 +119,6 @@ class ChallengeFiltersExample extends React.Component { */ } - /** - * Searches the challenges for with the specified search string, competition - * tracks, and filters. - * - * As TopCoder API v2 does not provide all necessary search & filtering - * capabilites, this function fetches all challenges from the requested - * tracks, then filters them by searching for 'searchString' in challenge - * name, platforms, and techologies, and by filtering them with 'filter' - * function, and then sets the remaining challenges into the component state. - * - * @param {String} searchString The search string. - * @param {Function(Challenge)} filter Additional filter function. - */ - onSearch(searchString) { - const f = new ChallengeFilterWithSearch(); - _.merge(f, this.getFilter()); - f.query = searchString; - if (f.query) this.onFilterByTopFilter(f); - else this.saveFiltersToHash(this.getFilter()); - } - onFilterByTopFilter(filter, isSidebarFilter) { let updatedFilter; if (filter.query && filter.query !== '') { @@ -336,7 +303,6 @@ class ChallengeFiltersExample extends React.Component {
this.onFilterByTopFilter(topFilter)} onSaveFilter={(filterToSave) => { if (this.sidebar) { @@ -347,12 +313,7 @@ class ChallengeFiltersExample extends React.Component { }} challengeGroupId={this.props.challengeGroupId} communityName={this.props.communityName} - searchQuery={this.getSearchQuery()} - onSearch={query => this.onSearch(query)} - validKeywords={this.props.challengeTags.map(keywordsMapper)} - validSubtracks={this.props.challengeSubtracks.map(keywordsMapper)} setCardType={_.noop/* cardType => this.setCardType(cardType) */} - isCardTypeSet={'Challenges' /* this.state.currentCardType */} ref={(node) => { this.challengeFilters = node; }} /> @@ -437,18 +398,13 @@ ChallengeFiltersExample.defaultProps = { COMMUNITY_URL: config.COMMUNITY_URL, }, myChallenges: [], - // challengeFilters: undefined, isAuth: false, masterFilterFunc: () => true, auth: null, }; ChallengeFiltersExample.propTypes = { - challenges: PT.arrayOf(PT.shape({ - - })).isRequired, - challengeSubtracks: PT.arrayOf(PT.string).isRequired, - challengeTags: PT.arrayOf(PT.string).isRequired, + challenges: PT.arrayOf(PT.shape()).isRequired, communityName: PT.string, filter: PT.string.isRequired, filterState: PT.shape().isRequired, @@ -466,7 +422,6 @@ ChallengeFiltersExample.propTypes = { }), challengeGroupId: PT.string, myChallenges: PT.arrayOf(PT.shape), - // challengeFilters: PT.object, isAuth: PT.bool, masterFilterFunc: PT.func, auth: PT.shape(), diff --git a/src/shared/containers/ChallengeListing/index.jsx b/src/shared/containers/ChallengeListing/index.jsx index 4954ef1edc..2a0395a239 100644 --- a/src/shared/containers/ChallengeListing/index.jsx +++ b/src/shared/containers/ChallengeListing/index.jsx @@ -46,8 +46,6 @@ class ChallengeListingPageContainer extends React.Component { } componentDidMount() { - const { challengeListing: cl } = this.props; - this.props.markHeaderMenu(); if (mounted) { @@ -55,9 +53,6 @@ class ChallengeListingPageContainer extends React.Component { } else mounted = true; this.loadChallenges(); - if (!cl.loadingChallengeSubtracks) this.props.getChallengeSubtracks(); - if (!cl.loadingChallengeTags) this.props.getChallengeTags(); - /* Get filter from the URL hash, if necessary. */ const filter = this.props.location.hash.slice(1); if (filter && filter !== this.props.challengeListing.filter) { @@ -214,8 +209,6 @@ ChallengeListingPageContainer.propTypes = { getAllChallenges: PT.func.isRequired, getAllMarathonMatches: PT.func.isRequired, getChallenges: PT.func.isRequired, - getChallengeSubtracks: PT.func.isRequired, - getChallengeTags: PT.func.isRequired, getMarathonMatches: PT.func.isRequired, markHeaderMenu: PT.func.isRequired, setFilter: PT.func.isRequired, @@ -311,14 +304,6 @@ function mapDispatchToProps(dispatch) { getAllMarathonMatches: (...rest) => getAllMarathonMatches(dispatch, ...rest), getChallenges: (...rest) => getChallenges(dispatch, ...rest), - getChallengeSubtracks: () => { - dispatch(a.getChallengeSubtracksInit()); - dispatch(a.getChallengeSubtracksDone()); - }, - getChallengeTags: () => { - dispatch(a.getChallengeTagsInit()); - dispatch(a.getChallengeTagsDone()); - }, getMarathonMatches: (...rest) => getMarathonMatches(dispatch, ...rest), reset: () => dispatch(a.reset()), setFilter: f => dispatch(a.setFilter(f)), diff --git a/src/shared/containers/challenge-listing/FilterPanel.js b/src/shared/containers/challenge-listing/FilterPanel.js deleted file mode 100644 index ec24599c97..0000000000 --- a/src/shared/containers/challenge-listing/FilterPanel.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Container for the header filters panel. - */ - -import actions from 'actions/challenge-listing/filter-panel'; -import challengeListingActions from 'actions/challenge-listing'; -import FilterPanel from 'components/challenge-listing/Filters/ChallengeFilters'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; - -function mapDispatchToProps(dispatch) { - const a = actions.challengeListing.filterPanel; - const cla = challengeListingActions.challengeListing; - return { - ...bindActionCreators(a, dispatch), - setFilterState: s => dispatch(cla.setFilterState(s)), - }; -} - -function mapStateToProps(state, ownProps) { - return { - ...ownProps, - ...state.challengeListing.filterPanel, - filterState: state.challengeListing.filterState, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(FilterPanel); diff --git a/src/shared/containers/challenge-listing/FilterPanel.jsx b/src/shared/containers/challenge-listing/FilterPanel.jsx new file mode 100644 index 0000000000..90caacdbbc --- /dev/null +++ b/src/shared/containers/challenge-listing/FilterPanel.jsx @@ -0,0 +1,62 @@ +/** + * Container for the header filters panel. + */ + +import actions from 'actions/challenge-listing/filter-panel'; +import challengeListingActions from 'actions/challenge-listing'; +import FilterPanel from 'components/challenge-listing/Filters/ChallengeFilters'; +import PT from 'prop-types'; +import React from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +class Container extends React.Component { + + componentDidMount() { + if (!this.props.loadingSubtracks) this.props.getSubtracks(); + if (!this.props.loadingKeywords) this.props.getKeywords(); + } + + render() { + return ; + } +} + +Container.propTypes = { + getKeywords: PT.func.isRequired, + getSubtracks: PT.func.isRequired, + loadingKeywords: PT.bool.isRequired, + loadingSubtracks: PT.bool.isRequired, +}; + +function mapDispatchToProps(dispatch) { + const a = actions.challengeListing.filterPanel; + const cla = challengeListingActions.challengeListing; + return { + ...bindActionCreators(a, dispatch), + getSubtracks: () => { + dispatch(cla.getChallengeSubtracksInit()); + dispatch(cla.getChallengeSubtracksDone()); + }, + getKeywords: () => { + dispatch(cla.getChallengeTagsInit()); + dispatch(cla.getChallengeTagsDone()); + }, + setFilterState: s => dispatch(cla.setFilterState(s)), + }; +} + +function mapStateToProps(state, ownProps) { + const cl = state.challengeListing; + return { + ...ownProps, + ...state.challengeListing.filterPanel, + filterState: cl.filterState, + loadingKeywords: cl.loadingChallengeTags, + loadingSubtracks: cl.loadingChallengeSubtracks, + validKeywords: cl.challengeTags, + validSubtracks: cl.challengeSubtracks, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Container); diff --git a/src/shared/reducers/challenge-listing.js b/src/shared/reducers/challenge-listing.js index 5ad5b7d6f3..d33a778d2f 100644 --- a/src/shared/reducers/challenge-listing.js +++ b/src/shared/reducers/challenge-listing.js @@ -305,6 +305,8 @@ function create(initialState) { counts: {}, filter: (new SideBarFilter()).getURLEncoded(), filterState: {}, + loadingChallengeSubtracks: false, + loadingChallengeTags: false, oldestData: Date.now(), pendingRequests: {}, })); From 33cff7887ae0b64c31c6b45a8eda01321d564a6c Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 19:05:51 +0200 Subject: [PATCH 07/20] Challenge Keywords and Subtracks filters are rewired to Redux --- src/shared/components/Select.jsx | 1 + .../Filters/ChallengeFilters.jsx | 2 + .../Filters/FiltersPanel/index.jsx | 43 +++++------ src/shared/utils/challenge-listing/filter.js | 71 ++++++++++++++++++- 4 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/shared/components/Select.jsx b/src/shared/components/Select.jsx index 97d6031063..0dfcaed88d 100644 --- a/src/shared/components/Select.jsx +++ b/src/shared/components/Select.jsx @@ -4,6 +4,7 @@ import PT from 'prop-types'; import ReactSelect from 'react-select'; import 'react-select/dist/react-select.css'; +/* TODO: Do we really need the instanceId? */ export default function Select(props) { return ( diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 54e9c0bc5d..6f1d07adbb 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -176,11 +176,13 @@ class ChallengeFilters extends React.Component { communityName={this.props.communityName} hidden={!expanded} filter={this.state.filter} + filterState={filterState} onClose={() => setExpanded(false)} onClearFilters={() => this.onClearFilters()} onFilter={filter => this.onFilter(filter)} onSaveFilter={() => this.props.onSaveFilter(this.state.filter)} ref={(node) => { this.filtersPanel = node; }} + setFilterState={setFilterState} validKeywords={this.props.validKeywords} validSubtracks={this.props.validSubtracks} /> diff --git a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx index bec654c78e..7d8de2b737 100644 --- a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx +++ b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx @@ -20,6 +20,7 @@ */ import _ from 'lodash'; +import * as Filter from 'utils/challenge-listing/filter'; import React from 'react'; import PT from 'prop-types'; import Select from 'components/Select'; @@ -71,28 +72,6 @@ class FiltersPanel extends React.Component { this.setState({ filter }); } - /** - * Handles updates of the keywords filter. - * @param {Array} keywords An array of selected keywords. - */ - onKeywordsChanged(keywords) { - const filter = new FilterPanelFilter(this.state.filter); - filter.keywords = keywords; - this.props.onFilter(filter); - this.setState({ filter }); - } - - /** - * Handles updates of the subtracks filter. - * @param {Array} subtracks An array of selected subtracks. - */ - onSubtracksChanged(subtracks) { - const filter = new FilterPanelFilter(this.state.filter); - filter.subtracks = subtracks; - this.props.onFilter(filter); - this.setState({ filter }); - } - /** * Triggers the 'onFilter' callback, if it is provided in properties. */ @@ -102,6 +81,8 @@ class FiltersPanel extends React.Component { render() { const { + filterState, + setFilterState, validKeywords, validSubtracks, } = this.props; @@ -137,10 +118,13 @@ class FiltersPanel extends React.Component { this.onSubtracksChanged(value ? value.split(',') : [])} + onChange={(value) => { + const subtracks = value ? value.split(',') : undefined; + setFilterState(Filter.setSubtracks(filterState, subtracks)); + }} options={validSubtracks.map(mapOps)} simpleValue - value={this.state.filter.subtracks.join(',')} + value={ + filterState.subtracks ? filterState.subtracks.join(',') : null + } />
@@ -221,10 +210,12 @@ FiltersPanel.propTypes = { challengeGroupId: PT.string.isRequired, communityName: PT.string, filter: PT.instanceOf(FilterPanelFilter), + filterState: PT.shape().isRequired, hidden: PT.bool, onClearFilters: PT.func, onFilter: PT.func, onSaveFilter: PT.func, + setFilterState: PT.func.isRequired, validKeywords: PT.arrayOf(PT.string).isRequired, validSubtracks: PT.arrayOf(PT.string).isRequired, onClose: PT.func, diff --git a/src/shared/utils/challenge-listing/filter.js b/src/shared/utils/challenge-listing/filter.js index 6d071a393f..ce2c81d010 100644 --- a/src/shared/utils/challenge-listing/filter.js +++ b/src/shared/utils/challenge-listing/filter.js @@ -27,9 +27,12 @@ * status {Object} - Permits only the challenges with status matching one of * the keys of this object. * - * subtracks {Object} - Permits only the challenges belonging to at least one + * subtracks {Array} - Permits only the challenges belonging to at least one * of the competition subtracks presented as keys of this object. * + * tags {Array} - Permits only the challenges that have at least one of the + * tags within their platform and technology tags (keywords). + * * text {String} - Free-text string which will be matched against challenge * name, its platform and technology tags. If not found anywhere, the challenge * is filtered out. Case-insensitive. @@ -44,6 +47,38 @@ import _ from 'lodash'; import { COMPETITION_TRACKS } from 'utils/tc'; +/** + * Returns true if the challenge satisfies the subtracks filtering rule. + * @param {Object} challenge + * @param {Object} state + * @return {Boolean} + */ +function filterBySubtracks(challenge, state) { + if (!state.subtracks) return true; + + /* TODO: Although this is takend from the current code in prod, + * it probably does not work in all cases. It should be double-checked, + * why challenge subtracks in challenge objects are different from those + * return from the API as the list of possible subtracks. */ + const filterSubtracks = state.subtracks.map(item => + item.toLowerCase().replace(/ /g, '')); + const challengeSubtrack = challenge.subTrack.toLowerCase().replace(/_/g, ''); + return filterSubtracks.includes(challengeSubtrack); +} + +/** + * Returns true if the challenge satisfies the tags filtering rule. + * @param {Object} challenge + * @param {Object} state + * @return {Boolean} + */ +function filterByTags(challenge, state) { + if (!state.tags) return true; + const str = `${challenge.name} ${challenge.platforms} ${ + challenge.technologies}`.toLowerCase(); + return state.tags.some(tag => str.includes(tag.toLowerCase())); +} + /** * Returns true if the challenge satisfies the free-text filtering condition set * in the provided filter state. @@ -56,7 +91,7 @@ function filterByText(challenge, state) { const str = `${challenge.name} ${challenge.platforms} ${challenge.technologies}` .toLowerCase(); - return str.indexOf(state.text.toLowerCase()) >= 0; + return str.includes(state.text.toLowerCase()); } /** @@ -102,7 +137,9 @@ export function addTrack(state, track) { */ export function getFilterFunction(state) { return challenge => filterByTrack(challenge, state) - && filterByText(challenge, state); + && filterByText(challenge, state) + && filterByTags(challenge, state) + && filterBySubtracks(challenge, state); } /** @@ -124,6 +161,34 @@ export function removeTrack(state, track) { return res; } +/** + * Clones the state and sets the subtracks. + * @param {Object} state + * @param {Array} subtracks + * @return {Object} + */ +export function setSubtracks(state, subtracks) { + if (subtracks && subtracks.length) return { ...state, subtracks }; + if (!state.subtracks) return state; + const res = _.clone(state); + delete res.subtracks; + return res; +} + +/** + * Clones the state and sets the tags. + * @param {Object} state + * @param {Array} tags String array. + * @return {Object} + */ +export function setTags(state, tags) { + if (tags && tags.length) return { ...state, tags }; + if (!state.tags) return state; + const res = _.clone(state); + delete res.tags; + return res; +} + /** * Clones fitler state and sets the free-text string for the filtering by * challenge name and tags. To clear the string set it to anything evaluating From 30b7f06a602a58c98fb54917a05124a4b106fd7f Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Sun, 18 Jun 2017 20:17:18 +0200 Subject: [PATCH 08/20] Filters panel almost completely rewired to the Redux There just a few callbacks that demands to create some additional actions, which will be handled a bit later. --- .../Filters/ChallengeFilters.jsx | 312 ++++++++---------- .../Filters/ChallengeFilters.scss | 4 + .../Filters/EditTrackPanel/index.jsx | 2 +- .../Filters/FiltersPanel/index.jsx | 276 +++++++--------- .../Filters/FiltersSwitch/FiltersIcon.jsx | 44 --- .../Filters/FiltersSwitch/filters-icon.svg | 12 + .../Filters/FiltersSwitch/index.jsx | 8 +- .../Filters/FiltersSwitch/style.scss | 4 + .../Icons/UiSimpleRemove.jsx | 25 -- .../challenge-listing/Icons/forum.svg | 2 +- .../Icons/ui-simple-remove.svg | 16 + .../SideBarFilters/FilterItems/index.jsx | 2 +- .../components/challenge-listing/index.jsx | 11 +- src/shared/utils/challenge-listing/filter.js | 55 ++- 14 files changed, 348 insertions(+), 425 deletions(-) delete mode 100644 src/shared/components/challenge-listing/Filters/FiltersSwitch/FiltersIcon.jsx create mode 100644 src/shared/components/challenge-listing/Filters/FiltersSwitch/filters-icon.svg delete mode 100644 src/shared/components/challenge-listing/Icons/UiSimpleRemove.jsx create mode 100644 src/shared/components/challenge-listing/Icons/ui-simple-remove.svg diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index 6f1d07adbb..7148a07b2f 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -12,201 +12,155 @@ import { COMPETITION_TRACKS as TRACKS } from 'utils/tc'; import ChallengeFilter from './ChallengeFilter'; import ChallengeSearchBar from './ChallengeSearchBar'; import EditTrackPanel from './EditTrackPanel'; -import FiltersIcon from './FiltersSwitch/FiltersIcon'; +import FiltersIcon from './FiltersSwitch/filters-icon.svg'; import FiltersPanel from './FiltersPanel'; import FiltersSwitch from './FiltersSwitch'; import FiltersCardsType from './FiltersCardsType'; import './ChallengeFilters.scss'; -class ChallengeFilters extends React.Component { - - constructor(props) { - super(props); - this.state = { - filter: props.filter, - filtersCount: props.filter.count(), - }; - } - - componentWillReceiveProps(nextProps) { - if (this.props.filter !== nextProps.filter) { - this.setState({ - filter: nextProps.filter, - filtersCount: nextProps.filter.count(), - }); - } - } - - /** - * Clears the filters. - */ - onClearFilters() { - const filter = new ChallengeFilter(); - this.setState({ filter, filtersCount: 0 }); - this.props.onFilter(filter); - } - - /** - * Updates the count of active filters (displayed in the filter panel switch), - * caches the set of active filters for subsequent searches, and triggers the - * 'onFilter' callback provided by the parent component, if any. - * - * When the parent 'onFilter' callback is triggered, an auxiliary filter function - * is passed in as the first argument. That filter function should be passed into - * the .filter() method of the challenge objects array to perform the filtering. - * - * @param {Object} filters Filters object, received from the FiltersPanel component. - */ - onFilter(filter) { - const f = (new ChallengeFilter(this.state.filter)).merge(filter); - this.setState({ - filter: f, - filtersCount: f.count(), - }); - this.props.onFilter(f); - } - - /** - * Sets the keywords filter in the FilterPanel to the specified value. - * @param {String} keywords A comma-separated list of the keywords. - */ - setKeywords(keywords) { - if (this.filtersPanel) this.filtersPanel.onKeywordsChanged([keywords]); - } - - render() { - const { - expanded, - filterState, - setExpanded, - setFilterState, - showTrackModal, - trackModalShown, - } = this.props; - - const isTrackOn = track => - !filterState.tracks || Boolean(filterState.tracks[track]); - - const switchTrack = (track, on) => { - const act = on ? Filter.addTrack : Filter.removeTrack; - setFilterState(act(filterState, track)); - }; - - return ( -
-
- - setFilterState(Filter.setText(filterState, text))} - placeholder="Search Challenges" - query={this.props.searchText} - setQuery={this.props.setSearchText} - /> +export default function ChallengeFilters({ + challengeGroupId, + communityName, + expanded, + filterState, + isCardTypeSet, + searchText, + setCardType, + setExpanded, + setFilterState, + setSearchText, + showTrackModal, + trackModalShown, + validKeywords, + validSubtracks, +}) { + let filterRulesCount = 0; + if (filterState.tags) filterRulesCount += 1; + if (filterState.subtracks) filterRulesCount += 1; + if (filterState.endDate || filterState.startDate) filterRulesCount += 1; + + const isTrackOn = track => + !filterState.tracks || Boolean(filterState.tracks[track]); + + const switchTrack = (track, on) => { + const act = on ? Filter.addTrack : Filter.removeTrack; + setFilterState(act(filterState, track)); + }; + + return ( +
+
+ + setFilterState(Filter.setText(filterState, text))} + placeholder="Search Challenges" + query={searchText} + setQuery={setSearchText} + /> + { + isCardTypeSet === 'Challenges' ? + ( + + + switchTrack(TRACKS.DESIGN, on)} + /> + + + switchTrack(TRACKS.DEVELOP, on)} + /> + + + switchTrack(TRACKS.DATA_SCIENCE, on)} + /> + + + ) : '' + } + { - this.props.isCardTypeSet === 'Challenges' ? + isCardTypeSet === 'Challenges' ? ( - - - switchTrack(TRACKS.DESIGN, on)} - /> - - - switchTrack(TRACKS.DEVELOP, on)} - /> - - - switchTrack(TRACKS.DATA_SCIENCE, on)} - /> - + showTrackModal(true)} + role="button" + styleName="track-btn" + tabIndex={0} + > + Tracks + ) : '' } - - { - this.props.isCardTypeSet === 'Challenges' ? - ( - showTrackModal(true)} - role="button" - styleName="track-btn" - tabIndex={0} - > - Tracks - - - ) : '' - } - {/* TODO: Two components below are filter switch buttons for - * mobile and desktop views. Should be refactored to use the - * same component, which automatically changes its style depending - * on the viewport size. */} - setExpanded(!expanded)} - role="button" - styleName="filter-btn" - tabIndex={0} - > - - Filter - - + {/* TODO: Two components below are filter switch buttons for + * mobile and desktop views. Should be refactored to use the + * same component, which automatically changes its style depending + * on the viewport size. */} + setExpanded(!expanded)} + role="button" + styleName="filter-btn" + tabIndex={0} + > + + Filter -
- -
- ); - } + +
+ ); } ChallengeFilters.defaultProps = { communityName: null, filter: new ChallengeFilter(), isCardTypeSet: '', - onFilter: _.noop, onSaveFilter: _.noop, setCardType: _.noop, }; @@ -215,11 +169,9 @@ ChallengeFilters.propTypes = { challengeGroupId: PT.string.isRequired, communityName: PT.string, expanded: PT.bool.isRequired, - filter: PT.instanceOf(ChallengeFilter), filterState: PT.shape().isRequired, isCardTypeSet: PT.string, - onFilter: PT.func, - onSaveFilter: PT.func, + // onSaveFilter: PT.func, setCardType: PT.func, setExpanded: PT.func.isRequired, setFilterState: PT.func.isRequired, @@ -230,5 +182,3 @@ ChallengeFilters.propTypes = { validKeywords: PT.arrayOf(PT.string).isRequired, validSubtracks: PT.arrayOf(PT.string).isRequired, }; - -export default ChallengeFilters; diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.scss b/src/shared/components/challenge-listing/Filters/ChallengeFilters.scss index 55bb8ccee3..7bb45ec9bf 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.scss +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.scss @@ -90,6 +90,10 @@ margin-right: 10px; position: relative; top: 3px; + + path { + fill: #737380; + } } } } diff --git a/src/shared/components/challenge-listing/Filters/EditTrackPanel/index.jsx b/src/shared/components/challenge-listing/Filters/EditTrackPanel/index.jsx index 3711dd2d3f..ef2d8cb88d 100644 --- a/src/shared/components/challenge-listing/Filters/EditTrackPanel/index.jsx +++ b/src/shared/components/challenge-listing/Filters/EditTrackPanel/index.jsx @@ -21,7 +21,7 @@ import React from 'react'; import PT from 'prop-types'; import Switch from 'components/Switch'; -import UiSimpleRemove from '../../Icons/UiSimpleRemove'; +import UiSimpleRemove from '../../Icons/ui-simple-remove.svg'; import './style.scss'; const EditTrackPanel = props => ( diff --git a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx index 7d8de2b737..05b3824c1c 100644 --- a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx +++ b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx @@ -25,200 +25,154 @@ import React from 'react'; import PT from 'prop-types'; import Select from 'components/Select'; import moment from 'moment'; - -import FilterPanelFilter from './FilterPanelFilter'; -import UiSimpleRemove from '../../Icons/UiSimpleRemove'; +import UiSimpleRemove from '../../Icons/ui-simple-remove.svg'; import './style.scss'; import DateRangePicker from '../DateRangePicker'; -class FiltersPanel extends React.Component { - - constructor(props) { - super(props); - this.state = { - filter: props.filter, - }; - } - - componentWillReceiveProps(nextProps) { - if (this.props.filter !== nextProps.filter) { - this.setState({ - filter: nextProps.filter, - }); - } - } - - /** - * Clears the the filters. - * Note that this method does not call the onFilter() callback passed via props, - * if any, just the onClearFilters(). - */ - onClearFilters() { - this.props.onClearFilters(); - this.setState({ filter: new FilterPanelFilter() }); - } - - /** - * Handles updates of the dates filter. - * @param {Moment} startDate - * @param {Moment} endDate - */ - onDatesChanged(startDate, endDate) { - const filter = new FilterPanelFilter(this.state.filter); - filter.startDate = moment(startDate); - filter.endDate = moment(endDate); - this.props.onFilter(filter); - this.setState({ filter }); - } - - /** - * Triggers the 'onFilter' callback, if it is provided in properties. - */ - filter() { - this.props.onFilter(this.state.filter); +export default function FiltersPanel({ + challengeGroupId, + filterState, + hidden, + onClose, + onSaveFilter, + setFilterState, + validKeywords, + validSubtracks, +}) { + let className = 'FiltersPanel'; + if (hidden) className += ' hidden'; + + // let communityOps; + /* + if (this.props.challengeGroupId) { + communityOps = [{ + label: this.props.communityName, + value: this.props.communityName, + }, { + label: 'All', + value: 'all', + }]; } + */ - render() { - const { - filterState, - setFilterState, - validKeywords, - validSubtracks, - } = this.props; - - let className = 'FiltersPanel'; - if (this.props.hidden) className += ' hidden'; + const mapOps = item => ({ label: item, value: item }); - let communityOps; - if (this.props.challengeGroupId) { - communityOps = [{ - label: this.props.communityName, - value: this.props.communityName, - }, { - label: 'All', - value: 'all', - }]; - } - - const mapOps = item => ({ label: item, value: item }); - - return ( -
-
- Filters - this.props.onClose()}> - - -
-
-
-
- - { - if (value !== 'all') { - this.state.filter.groupId = this.props.challengeGroupId; - } else { - this.state.filter.groupId = null; - } - this.props.onFilter(this.state.filter); - }} - options={communityOps} - simpleValue - value={this.state.filter.groupId ? this.props.communityName : 'all'} - /> -
- ) : null} + return ( +
+
+ Filters + onClose()}> + + +
+
+
+
+ + { - const subtracks = value ? value.split(',') : undefined; - setFilterState(Filter.setSubtracks(filterState, subtracks)); + if (value !== 'all') { + this.state.filter.groupId = this.props.challengeGroupId; + } else { + this.state.filter.groupId = null; + } + this.props.onFilter(this.state.filter); }} - options={validSubtracks.map(mapOps)} + options={communityOps} simpleValue - value={ - filterState.subtracks ? filterState.subtracks.join(',') : null - } + value={this.state.filter.groupId ? this.props.communityName : 'all'} + */ + tmp />
-
- - { this.onDatesChanged(dates.startDate, dates.endDate)} - startDate={this.state.filter.startDate} - />} -
-
+ ) : null}
-
- - +
+
+ + props.onNameChange(event.target.value)} - onKeyPress={(event) => { - if (event.key === 'Enter') event.target.blur(); - }} - value={props.name} - type="text" - /> - - -
Delete Filter
-
-
- ); -} - -ActiveFilterItem.defaultProps = { - onDrag: _.noop, - onDragStart: _.noop, - onNameChange: _.noop, - onRemove: _.noop, -}; - -ActiveFilterItem.propTypes = { - name: PT.string.isRequired, - onRemove: PT.func, - onDrag: PT.func, - onDragStart: PT.func, - onNameChange: PT.func, -}; - -/** - * A single line in the sidebar in its normal mode. It shows the filter name and - * the count of matching items. Can be highlighted. - */ -function FilterItem(props) { - let baseClasses = 'FilterItem'; - if (props.highlighted) baseClasses += ' highlighted'; - return ( -
- {props.name} - {(props.name === 'Past challenges' || props.myFilter) ? '' : props.count} -
- ); -} - -FilterItem.defaultProps = { - highlighted: false, - onClick: _.noop, - myFilter: false, -}; - -FilterItem.propTypes = { - count: PT.number.isRequired, - highlighted: PT.bool, - onClick: PT.func, - name: PT.string.isRequired, - myFilter: PT.bool, -}; - -export { ActiveFilterItem, FilterItem }; diff --git a/src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/index.jsx b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/index.jsx new file mode 100644 index 0000000000..1599a265ea --- /dev/null +++ b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/index.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PT from 'prop-types'; + +import './style.scss'; + +import ArrowsMoveVertical from '../../../Icons/ArrowsMoveVertical'; +import UiSimpleRemove from '../../../Icons/ui-simple-remove.svg'; + +export default function Item(props) { + return ( +
props.dragSavedFilterMove(event)} + onDragStart={event => props.dragSavedFilterStart(event)} + > + + props.updateSavedFilter()} + onChange={event => props.changeFilterName(event.target.value)} + onKeyDown={(event) => { + switch (event.key) { + case 'Enter': return event.target.blur(); + case 'Escape': { + event.target.blur(); + return props.resetFilterName(); + } + default: return undefined; + } + }} + value={props.name} + type="text" + /> + + +
Delete Filter
+
+
+ ); +} + +Item.propTypes = { + deleteSavedFilter: PT.func.isRequired, + dragSavedFilterMove: PT.func.isRequired, + dragSavedFilterStart: PT.func.isRequired, + name: PT.string.isRequired, + changeFilterName: PT.func.isRequired, + resetFilterName: PT.func.isRequired, + updateSavedFilter: PT.func.isRequired, +}; diff --git a/src/shared/components/challenge-listing/Sidebar/FilterItems/style.scss b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/style.scss similarity index 71% rename from src/shared/components/challenge-listing/Sidebar/FilterItems/style.scss rename to src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/style.scss index 4b07614586..9e2820e5db 100644 --- a/src/shared/components/challenge-listing/Sidebar/FilterItems/style.scss +++ b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/Item/style.scss @@ -1,7 +1,5 @@ @import "~styles/tc-styles"; -// Styling of ActiveFilterItem. - .ActiveFilterItem { cursor: pointer; margin: 0 -2 * $base-unit; @@ -88,37 +86,3 @@ top: 30px; z-index: 10; } - -// Styling of FilterItem. - -.FilterItem { - color: $tc-black; - border-radius: 2 * $corner-radius; - cursor: pointer; - padding: 0 2 * $base-unit 0 3 * $base-unit; - - @include xs { - font-size: 15px; - padding: 2px 0; - } - - .right { - color: $tc-gray-50; - float: right; - position: static; - font-weight: 700; - } -} - -.FilterItem.highlighted { - background: $tc-gray-10; - cursor: default; - - @include xs { - background: $tc-white; - } - - .left { - font-weight: 600; - } -} diff --git a/src/shared/components/challenge-listing/Sidebar/FiltersEditor/index.jsx b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/index.jsx new file mode 100644 index 0000000000..e0661cfdbc --- /dev/null +++ b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/index.jsx @@ -0,0 +1,73 @@ +/** + * Content of the sidebar in filters editor mode. In that mode the sidebar + * allows to reorder / rename user-saved filters. + */ + +import PT from 'prop-types'; +import React from 'react'; + +import Item from './Item'; +import './style.scss'; + +export default function FiltersEditor({ + changeFilterName, + deleteSavedFilter, + dragState, + dragSavedFilterMove, + dragSavedFilterStart, + resetFilterName, + savedFilters, + setEditSavedFiltersMode, + updateAllSavedFilters, + updateSavedFilter, +}) { + const savedFilterItems = savedFilters.map((item, index) => ( + changeFilterName(index, name)} + deleteSavedFilter={() => deleteSavedFilter(item.id)} + dragSavedFilterMove={e => dragSavedFilterMove(e, dragState)} + dragSavedFilterStart={e => dragSavedFilterStart(index, e)} + resetFilterName={() => resetFilterName(index)} + key={item.id} + name={item.name} + updateSavedFilter={() => updateSavedFilter(item)} + /> + )); + + return ( +
+

+ My filters +

+
{ + updateAllSavedFilters(); + setEditSavedFiltersMode(false); + }} + role="button" + styleName="done-button" + tabIndex={0} + > + Done +
+ { savedFilterItems } +
+ Drag the filters to set the order you prefer; + use the "x" mark to delete the filter(s) you don't need. +
+
+ ); +} + +FiltersEditor.propTypes = { + changeFilterName: PT.func.isRequired, + deleteSavedFilter: PT.func.isRequired, + dragState: PT.shape().isRequired, + dragSavedFilterMove: PT.func.isRequired, + dragSavedFilterStart: PT.func.isRequired, + resetFilterName: PT.func.isRequired, + savedFilters: PT.arrayOf(PT.shape()).isRequired, + setEditSavedFiltersMode: PT.func.isRequired, + updateAllSavedFilters: PT.func.isRequired, + updateSavedFilter: PT.func.isRequired, +}; diff --git a/src/shared/components/challenge-listing/Sidebar/EditMyFilters/EditMyFilters.scss b/src/shared/components/challenge-listing/Sidebar/FiltersEditor/style.scss similarity index 100% rename from src/shared/components/challenge-listing/Sidebar/EditMyFilters/EditMyFilters.scss rename to src/shared/components/challenge-listing/Sidebar/FiltersEditor/style.scss diff --git a/src/shared/components/challenge-listing/Sidebar/index.jsx b/src/shared/components/challenge-listing/Sidebar/index.jsx index 37bcedf39c..cf35412cc9 100644 --- a/src/shared/components/challenge-listing/Sidebar/index.jsx +++ b/src/shared/components/challenge-listing/Sidebar/index.jsx @@ -15,478 +15,79 @@ * in that mode. */ -// import _ from 'lodash'; -import * as Filter from 'utils/challenge-listing/filter'; -import config from 'utils/config'; -// import uuid from 'uuid/v4'; import React from 'react'; import PT from 'prop-types'; -import { BUCKETS } from 'utils/challenge-listing/buckets'; -import Bucket from './Bucket'; +import BucketSelector from './BucketSelector'; +import FiltersEditor from './FiltersEditor'; import Footer from './Footer'; -// import EditMyFilters, { SAVE_FILTERS_API } from './EditMyFilters'; -// import { FilterItem } from './FilterItems'; import './style.scss'; -/* - * This auxiliary object holds the indices of standard filters in the filters array. - */ -/* -const FILTER_ID = { - ALL_CHALLENGES: 0, - MY_CHALLENGES: 1, - OPEN_FOR_REGISTRATION: 2, - ONGOING_CHALLENGES: 3, - PAST_CHALLENGES: 4, - OPEN_FOR_REVIEW: 5, - UPCOMING_CHALLENGES: 6, - FIRST_USER_DEFINED: 7, -}; - -/* - * Component modes. - */ -const MODES = { - EDIT_MY_FILTERS: 0, - SELECT_FILTER: 1, -}; - -/* - * When a new filter is added via the addFilter() method, its name is set equal - * to `${MY_FILTER_BASE_NAME} N` where N is least integers, which is still larger - * that all other such indices in the similar filter names. - */ -// const MY_FILTER_BASE_NAME = 'My Filter'; - -const RSS_LINK = 'http://feeds.topcoder.com/challenges/feed?list=active&contestType=all'; - -class SideBarFilters extends React.Component { - - static domainFromUrl(url) { - // if MAIN_URL is not defined or null return default domain (production) - if (url == null) { - return 'topcoder.com'; - } - const firstSlashIndex = url.indexOf('/'); - const secondSlashIndex = url.indexOf('/', firstSlashIndex + 1); - const fullDomainName = url.slice(secondSlashIndex + 1); - const lastDotIndex = fullDomainName.lastIndexOf('.'); - const secondLastDotIndex = fullDomainName.lastIndexOf('.', lastDotIndex - 1); - if (secondLastDotIndex === -1) { - return fullDomainName; - } - return fullDomainName.slice(secondLastDotIndex + 1, fullDomainName.length); - } - - /* - constructor(props) { - super(props); - - // const { challengeGroupId: cgi } = props; - - /* const authToken = (props.auth && props.auth.tokenV2) || null; - - this.state = { - authToken, - mode: MODES.SELECT_FILTER, - }; - - /* - for (let i = 0; i < this.state.filters.length; i += 1) { - const item = this.state.filters[i]; - item.count = props.challenges.filter(item.getFilterFunction()) - .filter(it => !cgi || it.groups[cgi]).length; - } - for (let i = 0; i !== this.state.filters.length; i += 1) { - // const f = this.state.filters[i]; - // Match of UUID means that one of the filters we have already matches - // the one passed from the parent component, so we have just select it, - // and we can exit the constructor right after. - /* - if (f.uuid === props.filter.uuid) { - this.state.currentFilter = f; - return; - } - */ - // } - // } - - /** - * Retrieve the saved filters for a logged in user. - */ - /* - componentDidMount() { - if (this.state.authToken) { - fetch(SAVE_FILTERS_API, { - headers: { - Authorization: `Bearer ${this.state.authToken}`, - 'Content-Type': 'application/json', - }, - }) - .then(res => res.json()) - .then((data) => { - console.log(data); - const myFilters = data.map((item) => { - const filter = item; - filter.isSavedFilter = true; - filter.isCustomFilter = true; - return new SideBarFilter(filter); - }); - this.setState({ - filters: this.state.filters.concat(myFilters), - }); - }); - } - } - */ - - /** - * When a new array of challenges is passed from the parent component via props, - * this method updates counters of challenges matching each of the filters in - * this sidebar. - */ - /* - componentWillReceiveProps(nextProps) { - const { challengeGroupId: cgi } = nextProps; - let currentFilter; - const filters = []; - this.state.filters.forEach((filter) => { - const filterClone = new SideBarFilter(filter); - if (this.state.currentFilter === filter) currentFilter = filterClone; - filterClone.groupId = nextProps.filter.groupId; - filterClone.count = nextProps.challenges.filter(filterClone.getFilterFunction()) - .filter(it => !cgi || it.groups[cgi]).length; - filters.push(filterClone); - }); - for (let i = 0; i < filters.length; i += 1) { - if (filters[i].mode === 'All Challenges') { - filters[i].count = 0; - for (let j = 0; j < filters.length; j += 1) { - if (filters[j].mode === 'Open for registration' - || filters[j].mode === 'Ongoing challenges') { - filters[i].count += filters[j].count; - } - } - } - } - this.setState({ - currentFilter, - filters, - }); - } - */ - - /** - * When sidebar updates, this method checks that some of the fitlers is highlighted, - * if not, it resets the current filter to the All Challenges. - * This allows to handle properly the following situation: - * - The user selects a custom filter from My Filters; - * - Then it clicks Edit My Filters and remove that filter; - * - Then he clicks Done and returns to the standard component mode. - * Without this method, he will still see the set of challenges filtered by - * the already removed filter, and no indication in the sidebar, by what filtered - * they are filtered. - */ - componentDidUpdate() { - /* - if (this.state.filters.indexOf(this.state.currentFilter) < 0) { - this.selectFilter(FILTER_ID.ALL_CHALLENGES); - } - */ - } - - /** - * Generates the default name for a new filter. - * It will be `${MY_FILTER_BASE_NAME} N`, where N is an integer, which makes - * this filter name unique among other filters in the sidebar. - */ - // getAvailableFilterName() { - /* - let maxId = 0; - for (let i = FILTER_ID.FIRST_USER_DEFINED; i < this.state.filters.length; i += 1) { - const name = this.state.filters[i].name; - if (name.startsWith(MY_FILTER_BASE_NAME)) { - const id = Number(name.slice(1 + MY_FILTER_BASE_NAME.length)); - if (!isNaN(id) && (maxId < id)) maxId = id; - } - } - return `${MY_FILTER_BASE_NAME} ${1 + maxId}`; - */ -// } - - /** - * Adds new custom filter to the sidebar. - * @param {String} filter.name Name of the filter to show in the sidebar. - * @param {Func} filter.filter Filter function, which should be serializable - * via toString() and deserializable via eval() (i.e. it should not depend on - * variables/functions in its outer scope). - */ - /* - addFilter(filter) { - const f = (new SideBarFilter(MODE.CUSTOM)).merge(filter); - f.uuid = uuid(); - const filters = _.clone(this.state.filters); - f.count = this.props.challenges.filter(f.getFilterFunction()).length; - filters.push(f); - this.setState({ filters }); - this.saveFilters(filters.slice(FILTER_ID.FIRST_USER_DEFINED)); - } - */ - - /** - * Renders the component in the Edit My Filters mode. - */ - /* - editMyFiltersMode() { - return null; - return ( -
-
- { - const filters = _.clone(this.state.filters).slice(0, FILTER_ID.FIRST_USER_DEFINED); - this.setState({ - filters: filters.concat(myFilters), - mode: MODES.SELECT_FILTER, - }); - this.updateFilters(myFilters); - }} +export default function SideBarFilters(props) { + return ( +
+
+ { props.editSavedFiltersMode ? ( + -
-
-
- ); - } - */ - -/** - * Updates already saved filters on the backend. - * Used to update name of the filter but can be used to update - * other properties if needed. - */ -/* - updateFilters(filters) { - // For each filter in filters, serialize it and then - // make a fetch PUT request - // there is no need to do anything with the response - filters.forEach((filter) => { - fetch(`${SAVE_FILTERS_API}/${filter.uuid}`, { - headers: { - Authorization: `Bearer ${this.state.authToken}`, - 'Content-Type': 'application/json', - }, - method: 'PUT', - body: JSON.stringify({ - name: filter.name, - filter: filter.getURLEncoded(), - // TODO: The saved-search API requires type to be one of develop, design, - // or data. As this is not consistent with the frontend functionality, the API - // needs to be updated in future, till then we use hardcoded 'develop'. - type: 'develop', - }), - }); - }); - } - /** - * Saves My Filters to the backend - */ - /* - saveFilters(filters) { - // This code saves the stringified representation of - // the filters to the remote server. - const [filter] = _.takeRight(filters); - - fetch(SAVE_FILTERS_API, { - headers: { - Authorization: `Bearer ${this.state.authToken}`, - 'Content-Type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ - name: this.getAvailableFilterName(), - filter: filter.getURLEncoded(), - // The saved-search API requires type to be one of develop, design, - // or data. We are using the filter property to store tracks info and passing - // in type as develop just to keep the backend happy. - type: 'develop', - }), - }) - .then(res => res.json()) - .then((res) => { - // Replace the SideBarFilter object created at the client side with a new - // SideBarFilter object which has correct id from the server response. - const updatedFilters = this.state.filters.filter(e => e.uuid !== filter.uuid); - const savedFilter = res; - savedFilter.isSavedFilter = true; - savedFilter.isCustomFilter = true; - // updatedFilters.push(new SideBarFilter(savedFilter)); - this.setState({ filters: updatedFilters }); - }); - } - - /** - * Renders the component in the regular mode. - */ - selectFilterMode() { - // if (this.state.filters[FILTER_ID.ALL_CHALLENGES].count === 0) return null; - - const { - activeBucket, - buckets, - disabled, - isAuth, - savedFilters, - selectBucket, - } = this.props; - - const filter = Filter.getFilterFunction(this.props.filterState); - const challenges = this.props.challenges.filter(filter); - - const getBucket = bucket => ( - selectBucket(bucket)} - /> - ); - - /* - const filters = this.state.filters.map((filter_, index) => ( - = FILTER_ID.FIRST_USER_DEFINED} - key={`${filter_.name}-filter`} - name={filter_.name} - onClick={() => this.selectFilter(index)} - /> - )); - */ - // const myFilters = filters.slice(FILTER_ID.FIRST_USER_DEFINED); - return ( -
-
- {getBucket(BUCKETS.ALL)} - {isAuth ? getBucket(BUCKETS.MY) : null} - {getBucket(BUCKETS.OPEN_FOR_REGISTRATION)} - {getBucket(BUCKETS.ONGOING)} -
- { - disabled ? Open for review : ( - Open for review - ) - } - {getBucket(BUCKETS.PAST)} - {getBucket(BUCKETS.UPCOMING)} - { - savedFilters.length ? - : '' - } -
- -
-
+ ) : ( + + )}
- ); - } - - /** - * Selects the filter with the specified index. - */ - /* - selectFilter(index) { - if (this.state.filters[index].mode === 'Open for review') { - // Jump to Development Review Opportunities page - window.location.href = `${this.props.config.MAIN_URL} - /review/development-review-opportunities/`; - } else { - const currentFilter = this.state.filters[index]; - this.setState({ currentFilter }, () => this.props.onFilter(currentFilter)); - } - } -*/ - /** - * Selects the filter with the specified name. - */ - /* - selectFilterWithName(filterName) { - // find a filter with matching name - const selectedFilter = _.find(this.state.filters, filter => filter.name === filterName); - if (selectedFilter.mode === 'Open for review') { - // Jump to Development Review Opportunities page - window.location.href = - `${this.props.config.MAIN_URL}/review/development-review-opportunities/`; - return; - } - const mergedFilter = this.props.filter.copySidebarFilterProps(selectedFilter); - this.setState({ currentFilter: mergedFilter }, () => this.props.onFilter(mergedFilter)); - } - */ - - /** - * Renders the component. - */ - render() { - return this.selectFilterMode(); - /* - switch (this.state.mode) { - case MODES.SELECT_FILTER: return this.selectFilterMode(); - case MODES.EDIT_MY_FILTERS: return this.editMyFiltersMode(); - default: return
; - } - */ - } +
+
+ ); } SideBarFilters.defaultProps = { disabled: false, + dragState: {}, isAuth: false, - // challengeGroupId: '', - // auth: null, }; SideBarFilters.propTypes = { activeBucket: PT.string.isRequired, + activeSavedFilter: PT.number.isRequired, buckets: PT.shape().isRequired, challenges: PT.arrayOf(PT.shape({ registrationOpen: PT.string.isRequired, })).isRequired, + changeFilterName: PT.func.isRequired, + deleteSavedFilter: PT.func.isRequired, disabled: PT.bool, + dragState: PT.shape(), + dragSavedFilterMove: PT.func.isRequired, + dragSavedFilterStart: PT.func.isRequired, + editSavedFiltersMode: PT.bool.isRequired, filterState: PT.shape().isRequired, isAuth: PT.bool, - // challengeGroupId: PT.string, - // savedFilters: PT.arrayOf(PT.shape).isRequired, - /* - auth: PT.shape({ - tokenV2: PT.string, - }), - */ + resetFilterName: PT.func.isRequired, savedFilters: PT.arrayOf(PT.shape()).isRequired, selectBucket: PT.func.isRequired, + selectSavedFilter: PT.func.isRequired, + setEditSavedFiltersMode: PT.func.isRequired, + updateAllSavedFilters: PT.func.isRequired, + updateSavedFilter: PT.func.isRequired, }; - -export default SideBarFilters; diff --git a/src/shared/components/challenge-listing/Sidebar/style.scss b/src/shared/components/challenge-listing/Sidebar/style.scss index cfed620502..bfda4a8e47 100644 --- a/src/shared/components/challenge-listing/Sidebar/style.scss +++ b/src/shared/components/challenge-listing/Sidebar/style.scss @@ -17,69 +17,5 @@ height: 1px; margin: 2 * $base-unit; } - - .my-filters { - display: block; - position: relative; - padding: 0 3 * $base-unit; - margin-top: 7 * $base-unit; - - @include xs { - padding: 0; - } - - h1 { - display: inline-block; - margin: 0; - padding: 0; - font-weight: 500; - font-size: 12px; - color: $tc-gray-50; - letter-spacing: 0; - line-height: 30px; - border: none; - text-transform: uppercase; - - @include xs { - font-size: 15px; - } - } - - .edit-link { - float: right; - font-weight: 400; - font-size: 11px; - color: $tc-dark-blue; - line-height: $base-unit * 4; - cursor: pointer; - - @include xs { - font-size: 13px; - } - } - } - - .get-rss { - text-align: center; - - a { - color: $tc-gray-50; - font-size: 13px; - line-height: 30px; - } - } - } - - .openForReview { - color: $tc-black; - border-radius: 2 * $corner-radius; - cursor: pointer; - display: block; - padding: 0 2 * $base-unit 0 3 * $base-unit; - - @include xs { - font-size: 15px; - padding: 2px 0; - } } } diff --git a/src/shared/containers/challenge-listing/FilterPanel.jsx b/src/shared/containers/challenge-listing/FilterPanel.jsx index b47f942310..f0acc79f08 100644 --- a/src/shared/containers/challenge-listing/FilterPanel.jsx +++ b/src/shared/containers/challenge-listing/FilterPanel.jsx @@ -7,9 +7,16 @@ import challengeListingActions from 'actions/challenge-listing'; import FilterPanel from 'components/challenge-listing/Filters/ChallengeFilters'; import PT from 'prop-types'; import React from 'react'; +import sidebarActions from 'actions/challenge-listing/sidebar'; +import { BUCKETS } from 'utils/challenge-listing/buckets'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +/* The default name for user-saved challenge filters. An integer + * number will be appended to it, when necessary, to keep filter + * names unique. */ +const DEFAULT_SAVED_FILTER_NAME = 'My Filter'; + class Container extends React.Component { componentDidMount() { @@ -18,20 +25,63 @@ class Container extends React.Component { } render() { - return ; + return ( + { + const name = this.props.getAvailableFilterName(); + this.props.saveFilter( + name, this.props.filterState, this.props.tokenV2); + }} + setFilterState={(state) => { + this.props.setFilterState(state); + if (this.props.activeBucket === BUCKETS.SAVED_FILTER) { + this.props.selectBucket(BUCKETS.ALL); + } + }} + /> + ); } } +/** + * Returns a vacant name for the user saved filter. + * @param {Object} state Redux state. + * @return {String} + */ +function getAvailableFilterName(state) { + let res = DEFAULT_SAVED_FILTER_NAME; + let id = 0; + state.challengeListing.sidebar.savedFilters.forEach((f) => { + while (res === f.name) { + res = `${DEFAULT_SAVED_FILTER_NAME} ${id += 1}`; + } + }); + return res; +} + +Container.defaultProps = { + tokenV2: '', +}; + Container.propTypes = { + activeBucket: PT.string.isRequired, + filterState: PT.shape().isRequired, + getAvailableFilterName: PT.func.isRequired, getKeywords: PT.func.isRequired, getSubtracks: PT.func.isRequired, loadingKeywords: PT.bool.isRequired, loadingSubtracks: PT.bool.isRequired, + saveFilter: PT.func.isRequired, + selectBucket: PT.func.isRequired, + setFilterState: PT.func.isRequired, + tokenV2: PT.string, }; function mapDispatchToProps(dispatch) { const a = actions.challengeListing.filterPanel; const cla = challengeListingActions.challengeListing; + const sa = sidebarActions.challengeListing.sidebar; return { ...bindActionCreators(a, dispatch), getSubtracks: () => { @@ -42,6 +92,9 @@ function mapDispatchToProps(dispatch) { dispatch(cla.getChallengeTagsInit()); dispatch(cla.getChallengeTagsDone()); }, + saveFilter: (...rest) => + dispatch(sa.saveFilter(...rest)), + selectBucket: bucket => dispatch(sa.selectBucket(bucket)), setFilterState: s => dispatch(cla.setFilter(s)), }; } @@ -51,11 +104,14 @@ function mapStateToProps(state, ownProps) { return { ...ownProps, ...state.challengeListing.filterPanel, + activeBucket: cl.sidebar.activeBucket, filterState: cl.filter, + getAvailableFilterName: () => getAvailableFilterName(state), loadingKeywords: cl.loadingChallengeTags, loadingSubtracks: cl.loadingChallengeSubtracks, validKeywords: cl.challengeTags, validSubtracks: cl.challengeSubtracks, + tokenV2: state.auth.tokenV2, }; } diff --git a/src/shared/containers/challenge-listing/Listing/index.jsx b/src/shared/containers/challenge-listing/Listing/index.jsx index 410c336ebb..1b34207a11 100644 --- a/src/shared/containers/challenge-listing/Listing/index.jsx +++ b/src/shared/containers/challenge-listing/Listing/index.jsx @@ -11,6 +11,7 @@ import _ from 'lodash'; import actions from 'actions/challenge-listing'; +import filterPanelActions from 'actions/challenge-listing/filter-panel'; import headerActions from 'actions/topcoder_header'; import logger from 'utils/logger'; import React from 'react'; @@ -21,6 +22,7 @@ import Banner from 'components/tc-communities/Banner'; import NewsletterSignup from 'components/tc-communities/NewsletterSignup'; import shortid from 'shortid'; import sidebarActions from 'actions/challenge-listing/sidebar'; +import { BUCKETS } from 'utils/challenge-listing/buckets'; import style from './styles.scss'; // helper function to de-serialize query string to filter object @@ -157,13 +159,13 @@ class ListingContainer extends React.Component { render() { const { + activeBucket, challenges, challengeSubtracks, challengeTags, challengeGroupId, listingOnly, selectBucket, - sidebar, } = this.props; return (
@@ -184,7 +186,7 @@ class ListingContainer extends React.Component { } {/* eslint-enable max-len */} this.loadMorePast()} - setFilterState={this.props.setFilter} + setFilterState={(state) => { + this.props.setFilter(state); + this.props.setSearchText(state.text || ''); + if (activeBucket === BUCKETS.SAVED_FILTER) { + this.props.selectBucket(BUCKETS.ALL); + } + }} setSort={this.props.setSort} sorts={this.props.sorts} @@ -241,10 +249,9 @@ ListingContainer.propTypes = { markHeaderMenu: PT.func.isRequired, selectBucket: PT.func.isRequired, setFilter: PT.func.isRequired, - sidebar: PT.shape({ - activeBucket: PT.string.isRequired, - }).isRequired, + activeBucket: PT.string.isRequired, sorts: PT.shape().isRequired, + setSearchText: PT.func.isRequired, setSort: PT.func.isRequired, setLoadMore: PT.func.isRequired, loadMore: PT.shape().isRequired, @@ -267,10 +274,24 @@ ListingContainer.propTypes = { }).isRequired, }; -const mapStateToProps = state => ({ - auth: state.auth, - ..._.omit(state.challengeListing, ['filterPanel']), -}); +const mapStateToProps = (state) => { + const cl = state.challengeListing; + return { + auth: state.auth, + filter: cl.filter, + challenges: cl.challenges, + challengeSubtracks: cl.challengeSubtracks, + challengeTags: cl.challengeTags, + counts: cl.counts, + loadingChallengeSubtracks: cl.loadingChallengeSubtracks, + loadingChallengeTags: cl.loadingChallengeTags, + loadMore: cl.loadMore, + oldestData: cl.oldestData, + pendingRequests: cl.pendingRequests, + sorts: cl.sorts, + activeBucket: cl.sidebar.activeBucket, + }; +}; /** * Loads into redux all challenges matching the request. @@ -328,6 +349,7 @@ function getMarathonMatches(dispatch, filters, ...rest) { function mapDispatchToProps(dispatch) { const a = actions.challengeListing; const ah = headerActions.topcoderHeader; + const fpa = filterPanelActions.challengeListing.filterPanel; const sa = sidebarActions.challengeListing.sidebar; return { getAllChallenges: (...rest) => getAllChallenges(dispatch, ...rest), @@ -339,6 +361,7 @@ function mapDispatchToProps(dispatch) { selectBucket: bucket => dispatch(sa.selectBucket(bucket)), setFilter: state => dispatch(a.setFilter(state)), setLoadMore: (...rest) => dispatch(a.setLoadMore(...rest)), + setSearchText: text => dispatch(fpa.setSearchText(text)), setSort: (bucket, sort) => dispatch(a.setSort(bucket, sort)), markHeaderMenu: () => dispatch(ah.setCurrentNav('Compete', 'All Challenges')), diff --git a/src/shared/containers/challenge-listing/Sidebar.jsx b/src/shared/containers/challenge-listing/Sidebar.jsx index 7ab27bb20b..d894e643f7 100644 --- a/src/shared/containers/challenge-listing/Sidebar.jsx +++ b/src/shared/containers/challenge-listing/Sidebar.jsx @@ -4,6 +4,8 @@ import _ from 'lodash'; import actions from 'actions/challenge-listing/sidebar'; +import challengeListingActions from 'actions/challenge-listing'; +import filterPanelActions from 'actions/challenge-listing/filter-panel'; import PT from 'prop-types'; import React from 'react'; import Sidebar from 'components/challenge-listing/Sidebar'; @@ -20,10 +22,26 @@ class SidebarContainer extends React.Component { render() { const buckets = getBuckets(this.props.user && this.props.user.handle); + const tokenV2 = this.props.tokenV2; return ( this.props.deleteSavedFilter(id, tokenV2)} + selectSavedFilter={(index) => { + const filter = this.props.savedFilters[index].filter; + this.props.selectSavedFilter(index); + this.props.setFilter(filter); + this.props.setSearchText(filter.text || ''); + }} + updateAllSavedFilters={() => + this.props.updateAllSavedFilters( + this.props.savedFilters, + this.props.tokenV2, + ) + } + updateSavedFilter={filter => + this.props.updateSavedFilter(filter, this.props.tokenV2)} /> ); } @@ -35,14 +53,27 @@ SidebarContainer.defaultProps = { }; SidebarContainer.propTypes = { + deleteSavedFilter: PT.func.isRequired, getSavedFilters: PT.func.isRequired, + savedFilters: PT.arrayOf(PT.shape()).isRequired, + selectSavedFilter: PT.func.isRequired, + setFilter: PT.func.isRequired, + setSearchText: PT.func.isRequired, tokenV2: PT.string, + updateAllSavedFilters: PT.func.isRequired, + updateSavedFilter: PT.func.isRequired, user: PT.shape(), }; function mapDispatchToProps(dispatch) { const a = actions.challengeListing.sidebar; - return bindActionCreators(a, dispatch); + const cla = challengeListingActions.challengeListing; + const fpa = filterPanelActions.challengeListing.filterPanel; + return { + ...bindActionCreators(a, dispatch), + setFilter: filter => dispatch(cla.setFilter(filter)), + setSearchText: text => dispatch(fpa.setSearchText(text)), + }; } function mapStateToProps(state) { diff --git a/src/shared/reducers/challenge-listing/index.js b/src/shared/reducers/challenge-listing/index.js index 58d852acfd..3550dea30a 100644 --- a/src/shared/reducers/challenge-listing/index.js +++ b/src/shared/reducers/challenge-listing/index.js @@ -285,6 +285,7 @@ function onSetFilter(state, { payload }) { query = `?${qs.stringify(query, { encode: false })}`; window.history.replaceState(window.history.state, '', query); } + return { ...state, filter: payload, diff --git a/src/shared/reducers/challenge-listing/sidebar.js b/src/shared/reducers/challenge-listing/sidebar.js index 8f338016a8..a9da0f9a87 100644 --- a/src/shared/reducers/challenge-listing/sidebar.js +++ b/src/shared/reducers/challenge-listing/sidebar.js @@ -2,22 +2,159 @@ * Challenge listing sidebar reducer. */ +/* global alert */ +/* eslint-disable no-alert */ + import _ from 'lodash'; import actions from 'actions/challenge-listing/sidebar'; +import logger from 'utils/logger'; import { BUCKETS } from 'utils/challenge-listing/buckets'; import { handleActions } from 'redux-actions'; +const MAX_FILTER_NAME_LENGTH = 35; + +/** + * Handles changeFilterName action. + * @param {Object} state + * @param {Object} action + */ +function onChangeFilterName(state, { payload: { index, name } }) { + const savedFilters = _.clone(state.savedFilters); + savedFilters[index] = { + ...savedFilters[index], + name: name.slice(0, MAX_FILTER_NAME_LENGTH), + }; + if (_.isUndefined(savedFilters[index].savedName)) { + savedFilters[index].savedName = state.savedFilters[index].name; + } + return { ...state, savedFilters }; +} + +/** + * Handles outcome of the deleteSavedFilter action. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onDeleteSavedFilter(state, action) { + if (action.error) { + logger.error(action.payload); + return state; + } + const id = action.payload; + return { + ...state, + savedFilters: state.savedFilters.filter(item => item.id !== id), + }; +} + +function onDragSavedFilterMove(state, action) { + const dragState = _.clone(action.payload); + if (dragState.currentIndex < 0) dragState.currentIndex = 0; + else if (dragState.currentIndex >= state.savedFilters.length) { + dragState.currentIndex = state.savedFilters.length - 1; + } + const savedFilters = _.clone(state.savedFilters); + const [filter] = savedFilters.splice(state.dragState.currentIndex, 1); + savedFilters.splice(dragState.currentIndex, 0, filter); + return { + ...state, + dragState, + savedFilters, + }; +} + +function onDragSavedFilterStart(state, action) { + return { ...state, dragState: action.payload }; +} + +/** + * Handles outcome of saveFilter action. + * @param {Object} state + * @param {Object} action + */ +function onFilterSaved(state, action) { + if (action.error) { + logger.error(action.payload); + alert('Failed to save the filter!'); + return state; + } + return { + ...state, + activeBucket: BUCKETS.SAVED_FILTER, + activeSavedFilter: state.savedFilters.length, + savedFilters: state.savedFilters.concat({ + ...action.payload, + filter: JSON.parse(action.payload.filter), + }), + }; +} + +/** + * Resets filter name to the last one saved to the API. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onResetFilterName(state, action) { + const index = action.payload; + if (_.isUndefined(state.savedFilters[index].savedName)) return state; + const savedFilters = _.clone(state.savedFilters); + savedFilters[index] = { + ...savedFilters[index], + name: savedFilters[index].savedName, + }; + delete savedFilters[index].savedName; + return { ...state, savedFilters }; +} + +/** + * Handles outcome of the updateSavedFilterAction. + * @param {Object} state + * @param {Object} action + */ +function onUpdateSavedFilter(state, action) { + if (action.error) { + logger.error(action.payload); + return state; + } + const filter = action.payload; + const index = state.savedFilters.indexOf(item => item.id === filter.id); + const savedFilters = _.clone(state.savedFilters); + savedFilters[index] = filter; + savedFilters[index].filter = JSON.parse(filter.filter); + return { ...state, savedFilters }; +} + function create(initialState = {}) { const a = actions.challengeListing.sidebar; return handleActions({ - [a.getFilters]: (state, action) => ({ + [a.changeFilterName]: onChangeFilterName, + [a.deleteSavedFilter]: onDeleteSavedFilter, + [a.dragSavedFilterMove]: onDragSavedFilterMove, + [a.dragSavedFilterStart]: onDragSavedFilterStart, + [a.getSavedFilters]: (state, action) => ({ ...state, savedFilters: action.error ? [] : action.payload, }), + [a.resetFilterName]: onResetFilterName, + [a.saveFilter]: onFilterSaved, [a.selectBucket]: (state, { payload }) => ({ ...state, activeBucket: payload }), + [a.selectSavedFilter]: (state, { payload }) => ({ + ...state, + activeBucket: BUCKETS.SAVED_FILTER, + activeSavedFilter: payload, + }), + [a.setEditSavedFiltersMode]: (state, { payload }) => ({ + ...state, + editSavedFiltersMode: payload, + }), + [a.updateSavedFilter]: onUpdateSavedFilter, }, _.defaults(initialState, { activeBucket: BUCKETS.ALL, + activeSavedFilter: 0, + editSavedFiltersMode: false, savedFilters: [], })); } diff --git a/src/shared/services/user-settings.js b/src/shared/services/user-settings.js index 249045cbb0..b61a086942 100644 --- a/src/shared/services/user-settings.js +++ b/src/shared/services/user-settings.js @@ -4,7 +4,7 @@ * save user-defined filters in the challenge search. */ -// import _ from 'lodash'; +import _ from 'lodash'; import config from 'utils/config'; import Api from './api'; @@ -26,7 +26,8 @@ export default class UserSettings { * @return {Promise} */ deleteFilter(id) { - return this.private.api.delete(`/saved-searches/${id}`); + return this.private.api.delete(`/saved-searches/${id}`) + .then(res => (res.ok ? null : new Error(res.statusText))); } /** @@ -36,7 +37,20 @@ export default class UserSettings { getFilters() { return this.private.api.get('/saved-searches') .then(res => (res.ok ? res.json() : new Error(res.statusText))) - .then(res => res.filter(item => item.version === '1.0')); + .then(res => res.map((item) => { + /* NOTE: Previous version of the challenge listing saved filter in + * different format (like an URL query string). This try/catch block + * effectively differentiate between the old (unsupported) and new + * format of the filters. */ + let filter; + try { + filter = JSON.parse(item.filter); + } catch (e) { + _.noop(); + } + return { ...item, filter }; + })) + .then(res => res.filter(item => item.filter)); } /** @@ -46,9 +60,10 @@ export default class UserSettings { */ saveFilter(name, filter) { return this.private.api.postJson('/saved-searches', { - filter, + filter: JSON.stringify(filter), name, - }); + type: 'develop', + }).then(res => (res.ok ? res.json() : new Error(res.statusText))); } /** @@ -59,9 +74,10 @@ export default class UserSettings { */ updateFilter(id, name, filter) { return this.private.api.putJson(`/saved-searches/${id}`, { - filter, + filter: JSON.stringify(filter.filter), name, - }); + type: 'develop', + }).then(res => (res.ok ? res.json() : new Error(res.statusText))); } } diff --git a/src/shared/utils/challenge-listing/buckets.js b/src/shared/utils/challenge-listing/buckets.js index c16025cf28..dc50904e2e 100644 --- a/src/shared/utils/challenge-listing/buckets.js +++ b/src/shared/utils/challenge-listing/buckets.js @@ -10,6 +10,7 @@ export const BUCKETS = { OPEN_FOR_REGISTRATION: 'openForRegistration', ONGOING: 'ongoing', PAST: 'past', + SAVED_FILTER: 'saved-filter', UPCOMING: 'upcoming', }; From 52f1ae4e707cd04ae8122218466da749a2d45cea Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Wed, 28 Jun 2017 18:09:50 +0200 Subject: [PATCH 18/20] Solves remaining issues with loading of challenges into the listing --- src/shared/actions/challenge-listing/index.js | 291 +++++++-------- .../InfiniteList/generalHelpers.js | 56 --- .../challenge-listing/InfiniteList/index.jsx | 238 ------------ .../Listing/Bucket/index.jsx | 20 +- .../challenge-listing/Listing/index.jsx | 338 +++-------------- .../components/challenge-listing/index.jsx | 150 +------- .../challenge-listing/Listing/index.jsx | 230 ++++-------- .../reducers/challenge-listing/index.js | 344 ++++++------------ src/shared/services/challenges.js | 108 +++++- src/shared/utils/challenge-listing/buckets.js | 3 +- src/shared/utils/challenge-listing/filter.js | 8 + 11 files changed, 500 insertions(+), 1286 deletions(-) delete mode 100644 src/shared/components/challenge-listing/InfiniteList/generalHelpers.js delete mode 100644 src/shared/components/challenge-listing/InfiniteList/index.jsx diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index 24c10dca68..222fe39bd6 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -1,94 +1,40 @@ /** * Challenge listing actions. - * - * In the Redux state we keep an array of challenge objects loaded into the - * listing(s), and a set of UUIDs of pending requests to load more challenges. - * To load more challenges you should first dispatch GET_INIT action with some - * UUID (use shortid package to generate it), then one of GET_CHALLENGES, - * GET_MARATHON_MATCHES, GET_USER_CHALLENGES, or GET_USER_MARATHON_MATCHES - * actions with the same UUID and a set of authorization, filtering, and - * pagination options. Received challenges will be merged into the array of - * challenges stored in the state, with some filtering options appended to the - * challenge objects (so that we can filter them again at the frontend side: - * challenge objects received from the backend do not include some of the - * necessary data, like groupIds, lists of participating users, etc). - * - * RESET action allows to remove all loaded challenges and cancel any pending - * requests to load challenges (removing an UUID from the set of pending - * requests results in ignoring the response for that request). - * - * The backend includes into each response the total count of challenges - * matching the specified filtering options (the actual number of challenge - * objects included into the response might be smaller, due to the pagination - * params). If "count" argument was provided in the dispatched action, - * the total count of matching challenges from the response will be written - * into a special map of counts in the Redux state. */ import _ from 'lodash'; -import logger from 'utils/logger'; import { createActions } from 'redux-actions'; +import { decodeToken } from 'tc-accounts'; import { getService } from 'services/challenges'; /** - * Private. Common logic to get all challenge or marathon matches. - * @param {Function} getter getChallenges(..) or getMarathonMatches(..) - * @param {String} uuid - * @param {Object} filters - * @param {String} token - * @param {String} user - * @return {Promise} + * The maximum number of challenges to fetch in a single API call. Currently, + * the backend never returns more than 50 challenges, even when a higher limit + * was specified in the request. Thus, this constant should not be larger than + * 50 (otherwise the frontend code will miss to load some challenges). */ -function getAll(getter, uuid, filters, token, countCategory, user) { - /* API does not allow to get more than 50 challenges or MMs a time. */ - const LIMIT = 50; - let page = 0; - let res; - - /* Single iteration of the fetch procedure. */ - function iteration() { - return getter(uuid, filters, { - limit: LIMIT, - offset: LIMIT * page, - }, token, countCategory || 'count', user).then((next) => { - if (res) res.challenges = res.challenges.concat(next.challenges); - else res = next; - page += 1; - if (LIMIT * page < res.totalCount.value) return iteration(); - if (!countCategory) res.totalCount = null; - return res; - }); - } - - return iteration(); -} +const PAGE_SIZE = 50; /** - * Private. Common processing of promises returned from ChallengesService. - * @param {Object} promise - * @param {String} uuid - * @param {Object} filters - * @param {Object} countCategory - * @param {String} user - * @return {Promise} + * Private. Loads from the backend all challenges matching some conditions. + * @param {Function} getter Given params object of shape { limit, offset } + * loads from the backend at most "limit" challenges, skipping the first + * "offset" ones. Returns loaded challenges as an array. + * @param {Number} page Optional. Next page of challenges to load. + * @param {Array} prev Optional. Challenges loaded so far. */ -function handle(promise, uuid, filters, countCategory, user) { - return promise.catch((error) => { - logger.error(error); - return { - challenges: [], - totalCount: 0, - }; - }).then(res => ({ - challenges: res.challenges || [], - filters, - totalCount: countCategory ? { - category: countCategory, - value: res.totalCount, - } : null, - user: user || null, - uuid, - })); +function getAll(getter, page = 0, prev) { + /* Amount of challenges to fetch in one API call. 50 is the current maximum + * amount of challenges the backend returns, event when the larger limit is + * explicitely required. */ + + return getter({ + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }).then(({ challenges: chunk }) => { + if (!chunk.length) return prev || []; + return getAll(getter, 1 + page, prev ? prev.concat(chunk) : chunk); + }); } /** @@ -118,123 +64,148 @@ function getChallengeTagsDone() { } /** - * Gets a portion of challenges from the backend. - * @param {String} uuid Should match an UUID stored into the state by - * a previously dispatched GET_INIT action. Reducer will ignore the challenges - * loaded by this action, if the UUID has already been removed from the set of - * UUIDs of pending fetch challenge actions. Also, once the action results are - * processed, its UUID is removed from the set of pending action UUIDs. - * @param {Object} filters Optional. An object with filters to pass to the - * backend. - * @param {Object} params Optional. An object with params to pass to the backend - * (except of the filter param, which is set by the previous argument). - * @param {String} token Optional. Auth token for Topcoder API v3. Some of the - * challenges are visible only to the properly authenticated and authorized - * users. With this argument omitted you will fetch only public challenges. - * @param {String} countCategory Optional. Specifies the category whereh the - * total count of challenges returned by this request should be written. - * @param {String} user Optional. User handle. If specified, only challenges - * where this user has some role are loaded. - * @return {Promise} + * Notifies about reloading of all active challenges. The UUID is stored in the + * state, and only challenges fetched by getAllActiveChallengesDone action with + * the same UUID will be accepted into the state. + * @param {String} uuid + * @return {String} */ -function getChallenges(uuid, filters, params, token, countCategory, user) { - const service = getService(token); - const promise = user ? - service.getUserChallenges(user, filters, params) : - service.getChallenges(filters, params); - return handle(promise, uuid, filters, countCategory, user); +function getAllActiveChallengesInit(uuid) { + return uuid; } /** - * Calls getChallenges(..) recursively to get all challenges matching given - * arguments. Mind that API does not allow to get more than 50 challenges a - * time. You should use this function carefully, and never call it when there - * might be many challenges matching your request. It is originally intended - * to get all active challenges, as there never too many of them. + * Gets all active challenges (including marathon matches) from the backend. + * Once this action is completed any active challenges saved to the state before + * will be dropped, and the newly fetched ones will be stored there. * @param {String} uuid - * @param {Object} filters - * @param {String} token - * @param {String} countCategory - * @param {String} user + * @param {String} tokenV3 Optional. Topcoder auth token v3. Without token only + * public challenges will be fetched. With the token provided, the action will + * also fetch private challenges related to this user. * @return {Promise} */ -function getAllChallenges(uuid, filters, token, countCategory, user) { - return getAll(getChallenges, uuid, filters, token, countCategory, user); +function getAllActiveChallengesDone(uuid, tokenV3) { + const filter = { status: 'ACTIVE' }; + const service = getService(tokenV3); + const calls = [ + getAll(params => service.getChallenges(filter, params)), + getAll(params => service.getMarathonMatches(filter, params)), + ]; + let user; + if (tokenV3) { + user = decodeToken(tokenV3).handle; + calls.push(getAll(params => + service.getUserChallenges(user, filter, params))); + calls.push(getAll(params => + service.getUserMarathonMatches(user, filter, params))); + } + return Promise.all(calls).then(([ch, mm, uch, umm]) => { + const challenges = ch.concat(mm); + + /* uch and umm arrays contain challenges where the user is participating in + * some role. The same challenge are already listed in res array, but they + * are not attributed to the user there. This block of code marks user + * challenges in an efficient way. */ + if (uch) { + const set = new Set(); + uch.forEach(item => set.add(item.id)); + umm.forEach(item => set.add(item.id)); + challenges.forEach((item) => { + if (set.has(item.id)) { + /* It is fine to reassing, as the array we modifying is created just + * above within the same function. */ + item.users[user] = true; // eslint-disable-line no-param-reassign + } + }); + } + + return { uuid, challenges }; + }); } /** - * Writes specified UUID into the set of pending requests to load challenges. - * This allows (1) to understand whether we are waiting to load any challenges; - * (2) to cancel pending request by removing UUID from the set. - * @param {String} uuid + * Notifies the state that we are about to load the specified page of draft + * challenges. + * @param {Number} page + * @return {Object} */ -function getInit(uuid) { - return uuid; +function getDraftChallengesInit(uuid, page) { + return { uuid, page }; } /** - * Gets a portion of marathon matches from the backend. Parameters are the same - * as for getChallenges() function. - * @param {String} uuid - * @param {Object} filters - * @param {Object} params - * @param {String} token - * @param {String} countCategory - * @param {String} user Optional. User handle. If specified, only challenges - * where this user has some role are loaded. - * @param {Promise} + * Gets the specified page of draft challenges (including MMs). + * @param {Number} page Page of challenges to fetch. + * @param {String} tokenV3 Optional. Topcoder auth token v3. + * @param {Object} */ -function getMarathonMatches(uuid, filters, params, token, countCategory, user) { - const service = getService(token); - const promise = user ? - service.getUserMarathonMatches(user, filters, params) : - service.getMarathonMatches(filters, params); - return handle(promise, uuid, filters, countCategory, user); +function getDraftChallengesDone(uuid, page, tokenV3) { + const service = getService(tokenV3); + return Promise.all([ + service.getChallenges({ status: 'DRAFT' }, { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), + service.getMarathonMatches({ status: 'DRAFT' }, { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), + ]).then(([{ challenges: chunkA }, { challenges: chunkB }]) => + ({ uuid, challenges: chunkA.concat(chunkB) })); } /** - * Calls getMarathonMatches(..) recursively to get all challenges matching given - * arguments. Mind that API does not allow to get more than 50 challenges a - * time. You should use this function carefully, and never call it when there - * might be many challenges matching your request. It is originally intended - * to get all active challenges, as there never too many of them. - * @param {String} uuid - * @param {Object} filters - * @param {String} token - * @param {String} countCategory - * @param {String} user - * @return {Promise} + * Notifies the state that we are about to load the specified page of past + * challenges. + * @param {Number} page + * @return {Object} */ -function getAllMarathonMatches(uuid, filters, token, countCategory, user) { - return getAll(getMarathonMatches, uuid, filters, token, countCategory, user); +function getPastChallengesInit(uuid, page) { + return { uuid, page }; } /** - * This action tells Redux to remove all loaded challenges and to cancel - * any pending requests to load more challenges. + * Gets the specified page of past challenges (including MMs). + * @param {Number} page Page of challenges to fetch. + * @param {String} tokenV3 Optional. Topcoder auth token v3. + * @param {Object} */ -function reset() { - return undefined; +function getPastChallengesDone(uuid, page, tokenV3) { + const service = getService(tokenV3); + return Promise.all([ + service.getChallenges({ status: 'COMPLETED' }, { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), + service.getMarathonMatches({ status: 'PAST' }, { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), + ]).then(([{ challenges: chunkA }, { challenges: chunkB }]) => + ({ uuid, challenges: chunkA.concat(chunkB) })); } export default createActions({ CHALLENGE_LISTING: { - GET_ALL_CHALLENGES: getAllChallenges, - GET_ALL_MARATHON_MATCHES: getAllMarathonMatches, + DROP_CHALLENGES: _.noop, + + GET_ALL_ACTIVE_CHALLENGES_INIT: getAllActiveChallengesInit, + GET_ALL_ACTIVE_CHALLENGES_DONE: getAllActiveChallengesDone, GET_CHALLENGE_SUBTRACKS_INIT: _.noop, GET_CHALLENGE_SUBTRACKS_DONE: getChallengeSubtracksDone, + GET_CHALLENGE_TAGS_INIT: _.noop, GET_CHALLENGE_TAGS_DONE: getChallengeTagsDone, - GET_CHALLENGES: getChallenges, - GET_INIT: getInit, - GET_MARATHON_MATCHES: getMarathonMatches, - RESET: reset, + GET_DRAFT_CHALLENGES_INIT: getDraftChallengesInit, + GET_DRAFT_CHALLENGES_DONE: getDraftChallengesDone, + + GET_PAST_CHALLENGES_INIT: getPastChallengesInit, + GET_PAST_CHALLENGES_DONE: getPastChallengesDone, + SET_FILTER: _.identity, SET_SORT: (bucket, sort) => ({ bucket, sort }), - - SET_LOAD_MORE: (key, data) => ({ key, data }), }, }); diff --git a/src/shared/components/challenge-listing/InfiniteList/generalHelpers.js b/src/shared/components/challenge-listing/InfiniteList/generalHelpers.js deleted file mode 100644 index 6cabe0115a..0000000000 --- a/src/shared/components/challenge-listing/InfiniteList/generalHelpers.js +++ /dev/null @@ -1,56 +0,0 @@ -/* global - Promise, clearTimeout -*/ - -import _ from 'lodash'; - -const fetchPromise = Promise.resolve(); -const loadBatchSize = 50; -let lastItemReturnTimeout; - -// fetch items and then return them in batches -export function fetchAdditionalItems({ - currentItems, - itemUniqueIdentifier, - fetchItems, - successCallback, - finishCallback, -}) { - fetchPromise.then( - fetchItems().then((receivedItems) => { - let newItems = _.concat([], currentItems, receivedItems); - if (itemUniqueIdentifier) newItems = _.uniqBy(newItems, itemUniqueIdentifier); - const uniqueReceivedItems = _.slice(newItems, currentItems.length); - const receivedItemsInChunks = _.chunk(uniqueReceivedItems, loadBatchSize); - const chunkNumber = receivedItems.length; - - function returnReceivedItems(currentChunkIndex = 0) { - if (currentChunkIndex === chunkNumber) { - setTimeout(() => finishCallback(receivedItems)); - return; - } - - lastItemReturnTimeout = setTimeout(() => { - successCallback(receivedItemsInChunks[currentChunkIndex]); - - returnReceivedItems(currentChunkIndex + 1); - }); - } - - returnReceivedItems(); - }), - ); -} - -// clear item batch return timeout and stop the chain -export function stopNewItemReturnChain() { - clearTimeout(lastItemReturnTimeout); -} - -export function generateIds(numberToAdd, prefix, currentIndex) { - return _.times(numberToAdd, num => `${prefix}-${currentIndex + num}`); -} - -export function organizeItems(items, filter, sort) { - return _.sortBy(_.filter(items, filter), sort); -} diff --git a/src/shared/components/challenge-listing/InfiniteList/index.jsx b/src/shared/components/challenge-listing/InfiniteList/index.jsx deleted file mode 100644 index aef23a82a3..0000000000 --- a/src/shared/components/challenge-listing/InfiniteList/index.jsx +++ /dev/null @@ -1,238 +0,0 @@ -/* global - Math, Promise -*/ - -/* eslint react/no-unused-prop-types: 0 */ // this rule not working properly here - -/** - * This component handles the display of an infinite list of items as well as - * their sorting, filtering and any further loading. - * - * It takes an initial list of items and once the user scrolls to the bottom of - * the list. The component adds a batch of new item ids, loads a batch of templates - * with those ids, fetch more items and then load these items into the DOM in - * smaller batches to replace the templates with the ids. - * - * The above-mentioned behaviour will continue until the number of the cached - * items is equal to or more than the total item count passed in as props - * to the component. The total item count should be the total amount of items - * available for retrieval from the database. - */ - -import _ from 'lodash'; -import React, { Component } from 'react'; -import PT from 'prop-types'; -// import Waypoint from 'react-waypoint'; -import moment from 'moment'; - -import { - fetchAdditionalItems, - generateIds, - stopNewItemReturnChain, - organizeItems, -} from './generalHelpers'; - -const assignedIdKey = 'assignedId'; -// const loadpointBottomOffset = -150; -const initialPageIndex = -1; - -class InfiniteList extends Component { - - componentWillMount() { - this.initializeProperties(this.props, true); - } - - // from the new props determine what have changed and blow away cache - // and reload items based on new props - componentWillReceiveProps(nextProps) { - const { filter: oldFilter, sort: oldSort, uniqueIdentifier } = this.props; - const { itemCountTotal, filter, sort } = nextProps; - const [newlyOrganizedItems, oldOrganizedItems] = [ - [filter, sort], [oldFilter, oldSort], - ].map(organizers => organizeItems(this.state.items, organizers[0], organizers[1])); - const [newItemOrderRepresentation, oldItemOrderRepresentation] = [ - newlyOrganizedItems, oldOrganizedItems, - ].map(items => _.map(items, uniqueIdentifier).join('')); - - if (itemCountTotal !== this.props.itemCountTotal) { - stopNewItemReturnChain(); - this.initializeProperties(nextProps); - this.setLoadingStatus(false); - } else if (newItemOrderRepresentation !== oldItemOrderRepresentation) { - this.reCacheItemElements( - _.uniqBy(newlyOrganizedItems, uniqueIdentifier), - nextProps.renderItem, - ); - } - } - - componentWillUnmount() { - stopNewItemReturnChain(); - } - - onScrollToLoadPoint() { - if (this.state.newItemsCount === 0 - || this.state.loading - || this.state.items.length >= this.props.itemCountTotal) { return; } - - this.addBatchIds(); - - const { uniqueIdentifier } = this.props; - this.setLoadingStatus(true); - - fetchAdditionalItems({ - itemUniqueIdentifier: uniqueIdentifier, - currentItems: this.state.items, - fetchItems: () => this.fetchNewItems(), - finishCallback: (newItems) => { - this.state.newItemsCount = newItems.length ? newItems.length : 0; - this.currentPageIndex += 1; - this.setLoadingStatus(false); - }, - successCallback: newItems => this.addNewItems(newItems), - }); - } - - setLoadingStatus(status) { - if (this.state.loading !== status) this.setState({ loading: status }); - } - - addNewItems(newItems, nextProps = null) { - if (!newItems) return; - - const { items: existingItems, cachedItemElements } = this.state; - const { renderItem, sort, filter } = nextProps || this.props; - const { ids, idPrefix } = this; - const { length: existingItemCount } = existingItems; - - const stampedNewItems = newItems.map((item, index) => { - const idIndex = existingItemCount + index; - - return _.set(item, assignedIdKey, ids[idIndex] || `${idPrefix}-${idIndex}`); - }); - - const newElements = organizeItems(stampedNewItems, filter, sort) - .map(item => renderItem(item[assignedIdKey], item)); - - this.setState({ - items: existingItems.concat(stampedNewItems), - cachedItemElements: cachedItemElements.concat(newElements), - }); - } - - reCacheItemElements(organizedItems, renderItem) { - this.setState({ - cachedItemElements: organizedItems.map(item => renderItem(item[assignedIdKey], item)), - }); - } - - // initialize properties/state of the component - // load an initial number of items and then cache the rest from - // the passed-in items - initializeProperties(props, isMounting = false) { - const { items, batchNumber, sort } = props; - const sortedItems = organizeItems(items, () => true, sort); - const initialLoadNumber = batchNumber + (items.length % batchNumber); - - this.currentPageIndex = initialPageIndex; - - this.setState({ items: [], cachedItemElements: [] }, () => { - this.ids = []; - this.addBatchIds(initialLoadNumber); - this.addNewItems(sortedItems.slice(0, initialLoadNumber), props, isMounting); - this.cachedPassedInItems = sortedItems.slice(initialLoadNumber); - }); - } - - addBatchIds(numberToAdd) { - const { batchNumber } = this.props; - const { ids = [], idPrefix } = this; - - this.idPrefix = idPrefix || Math.random().toString(36).substring(7); - this.ids = ids.concat(generateIds(numberToAdd || batchNumber, this.idPrefix, ids.length)); - } - - // fetch new items either from cache or API endpoint - fetchNewItems() { - const { fetchItems, batchNumber, tempDataFilter } = this.props; - const { cachedPassedInItems } = this; - - if (cachedPassedInItems.length === 0) { - // conditions need to be removed once v3 api endpoint is created for - // Open for registration and ongoing challenges filters - if (tempDataFilter === 'Open for registration') { - return fetchItems(this.currentPageIndex + 1).then(data => data.filter((item) => { - const allphases = item.allPhases.filter(phase => phase.phaseType === 'Registration' && phase.phaseStatus === 'Open'); - return moment(item.registrationEndDate) > moment() && allphases && allphases.length > 0; - })); - } else if (tempDataFilter === 'Ongoing challenges') { - return fetchItems(this.currentPageIndex + 1).then(data => data.filter((item) => { - const allphases = item.allPhases.filter(phase => phase.phaseType === 'Registration' && phase.phaseStatus === 'Closed'); - return moment(item.registrationEndDate) < moment() && allphases && allphases.length > 0; - })); - } - return fetchItems(this.currentPageIndex + 1); - } - this.cachedPassedInItems = cachedPassedInItems.slice(batchNumber); - return Promise.resolve(cachedPassedInItems.slice(0, batchNumber)); - } - - render() { - const { cachedItemElements, items: { length: loadedCount } } = this.state; - const { ids } = this; - const { renderItemTemplate, batchNumber } = this.props; - let templates; - - if (this.state.loading) { - templates = _.slice(ids, loadedCount, loadedCount + batchNumber) - .map(id => renderItemTemplate(id)); - } else { - templates = []; - } - - return ( -
- {cachedItemElements} - {templates} - {/* - this.onScrollToLoadPoint()} - scrollableAncestor={window} - bottomOffset={loadpointBottomOffset} - key={Math.random()} - /> - */ - } -
- ); - } -} - -InfiniteList.defaultProps = { - itemCountTotal: 0, - batchNumber: 50, - fetchMoreItems: _.noop, - renderItemTemplate: _.noop, - filter: () => true, - sort: () => true, - fetchItems: null, - uniqueIdentifier: false, - renderItem: _.noop, - tempDataFilter: null, -}; - -// tempDataFilter prop is added for temporary use. Need to be removed once V3 api -// end points are created -InfiniteList.propTypes = { - itemCountTotal: PT.number, - batchNumber: PT.number, - fetchItems: PT.func, - renderItemTemplate: PT.func, - filter: PT.func, - sort: PT.func, - uniqueIdentifier: PT.oneOfType([PT.string, PT.bool]), - renderItem: PT.func, - tempDataFilter: PT.string, -}; - -export default InfiniteList; diff --git a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx index 09f1fb9ebe..ca7f96def7 100644 --- a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx +++ b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx @@ -20,7 +20,7 @@ export default function Bucket({ challenges, expanded, expand, - loadingMore, + loading, loadMore, setFilterState, setSort, @@ -44,7 +44,7 @@ export default function Bucket({ } } - if (!filteredChallenges.length) return null; + if (!filteredChallenges.length && !loadMore) return null; const cards = filteredChallenges.map(item => ( ); } @@ -80,7 +80,7 @@ export default function Bucket({ {cards} {placeholders} { - expandable ? ( + (expandable || loadMore) && !expanded ? (
@@ -103,7 +99,7 @@ export default function Bucket({ Bucket.defaultProps = { expanded: false, expand: _.noop, - loadingMore: false, + loading: false, loadMore: null, sort: null, }; @@ -113,7 +109,7 @@ Bucket.propTypes = { expanded: PT.bool, expand: PT.func, challenges: PT.arrayOf(PT.shape()).isRequired, - loadingMore: PT.bool, + loading: PT.bool, loadMore: PT.func, setFilterState: PT.func.isRequired, setSort: PT.func.isRequired, diff --git a/src/shared/components/challenge-listing/Listing/index.jsx b/src/shared/components/challenge-listing/Listing/index.jsx index e8d5facbd5..6d2f90b904 100644 --- a/src/shared/components/challenge-listing/Listing/index.jsx +++ b/src/shared/components/challenge-listing/Listing/index.jsx @@ -1,58 +1,21 @@ /** - * This component is responsible for displaying and handling the container - * interaction of challenges with respect to their filter categories. - * - * It uses the InfiniteList component to display the challenges in a list. It - * passes into InfiniteList all the necessary properties such as the selected - * sorting and filtering settings for rendering the challenges in the right - * order and format. Refer to that component for the list behaviour. - * - * It will also handle sorting in each category container and store the setting - * in sessionStorage. It will load the setting if it exists at the begining. It - * uses the SortingSelectBar component for letting the user select the sorting - * option for each challenge category. - * - * It loads from files, challengeFilters.js and sortingFunctionStore.js. The first - * file lets the component know all the challenge categories with their respective - * filtering settings, sorting options, API endpoints and other information. The - * second file lets the component know how to sort challenges for different sorting - * settings. These files are kept in this folder for now but should be moved to - * another place if it is more appropriate. + * The actual listing of the challenge cards. */ import _ from 'lodash'; import React from 'react'; import PT from 'prop-types'; -// import { normalizeChallenge, normalizeMarathonMatch } from 'reducers/challenge-listing'; -// import SortingSelectBar from 'components/SortingSelectBar'; import { BUCKETS, getBuckets } from 'utils/challenge-listing/buckets'; import Bucket from './Bucket'; -// import InfiniteList from '../InfiniteList'; -/* -import defaultSortingFunctionStore from './sortingFunctionStore'; -import { - getChallengeCardPlaceholder, - getChallengeCard, - getExpandBucketButton, -} from './childComponentConstructorHelpers'; -import { - getFilterChallengesStore, - getFilterSortingStore, - getFilterTotalCountStore, -} from './storeConstructorHelpers'; -import { - findFilterByName, - filterFilterChallengesStore, - isChallengeCategoryExpandable, -} from './generalHelpers'; -*/ import './style.scss'; export default function ChallengeCardContainer({ activeBucket, auth, challenges, - loadMore, + loadingDraftChallenges, + loadingPastChallenges, + loadMoreDraft, loadMorePast, selectBucket, setFilterState, @@ -63,14 +26,27 @@ export default function ChallengeCardContainer({ if ((activeBucket !== BUCKETS.ALL) && (activeBucket !== BUCKETS.SAVED_FILTER)) { + let loading; + let loadMore; + switch (activeBucket) { + case BUCKETS.PAST: + loading = loadingPastChallenges; + loadMore = loadMorePast; + break; + case BUCKETS.UPCOMING: + loading = loadingDraftChallenges; + loadMore = loadMoreDraft; + break; + default: break; + } return (
setSort(activeBucket, sort)} sort={sorts[activeBucket]} @@ -79,17 +55,33 @@ export default function ChallengeCardContainer({ ); } - const getBucket = bucket => ( - selectBucket(bucket)} - // loadMore={bucket === BUCKETS.PAST ? loadMorePast : null} - setFilterState={setFilterState} - setSort={sort => setSort(bucket, sort)} - sort={sorts[bucket]} - /> - ); + const getBucket = (bucket) => { + let loading; + let loadMore; + switch (bucket) { + case BUCKETS.PAST: + loading = loadingPastChallenges; + loadMore = loadMorePast; + break; + case BUCKETS.UPCOMING: + loading = loadingDraftChallenges; + loadMore = loadMoreDraft; + break; + default: break; + } + return ( + selectBucket(bucket)} + loading={loading} + loadMore={loadMore} + setFilterState={setFilterState} + setSort={sort => setSort(bucket, sort)} + sort={sorts[bucket]} + /> + ); + }; return (
@@ -102,218 +94,6 @@ export default function ChallengeCardContainer({ ); } -/* -const initialNumberToShow = 10; -const batchLoadNumber = 50; -const challengeUniqueIdentifier = 'id'; -*/ - -// class ChallengeCardContainer extends Component { - /* - constructor(props) { - super(props); - const { challenges, filters, currentFilterName, expanded } = props; - let userSessionFilterSortingStore; - - if (typeof sessionStorage !== 'undefined' && sessionStorage.challengeFilterSortingStore) { - userSessionFilterSortingStore = JSON.parse(sessionStorage.challengeFilterSortingStore); - } - - this.state = { - filterChallengesStore: getFilterChallengesStore(filters, challenges), - currentFilter: findFilterByName(currentFilterName, filters), - filterSortingStore: getFilterSortingStore(filters, userSessionFilterSortingStore), - sortingFunctionStore: defaultSortingFunctionStore, - filterTotalCountStore: {}, - expanded, - isLoaded: false, - isLoading: false, - }; - } - - /** - * ChallengeCardContainer was brought from another project without server rendering support. - * To make rendering on the server consistent with the client rendering, we have to make sure all - * setState calls will preform after this component is mounted. So we moved all the code which - * can call setState from the constructor to here. Also we added some logic to make sure we - * load data only once. - */ - /* - componentDidMount() { - if (!this.state.isLoading && !this.state.isLoaded) { - // eslint-disable-next-line react/no-did-mount-set-state - this.setState({ isLoading: true }); - getFilterTotalCountStore().then( - filterTotalCountStore => this.setState({ - filterTotalCountStore, - isLoading: false, - isLoaded: true, - }), - ); - } - } - - componentWillReceiveProps(nextProps) { - const { challenges, filters, currentFilterName, expanded } = nextProps; - const { filterSortingStore } = this.state; - - this.setState({ - filterChallengesStore: getFilterChallengesStore(filters, challenges), - currentFilter: findFilterByName(currentFilterName, filters), - filterSortingStore: getFilterSortingStore(filters, filterSortingStore), - expanded, - }); - } - - onExpandFilterResult(filterName) { - this.setState({ - currentFilter: findFilterByName(filterName, this.props.filters), - expanded: true, - }, this.props.onExpandFilterResult(filterName)); // pass filterName - } - - onSortingSelect(filterName, sortingOptionName) { - const filterSortingStore = _.assign( - {}, - this.state.filterSortingStore, - { [filterName]: sortingOptionName }, - ); - sessionStorage.challengeFilterSortingStore = JSON.stringify(filterSortingStore); - - this.setState({ filterSortingStore }); - } - - render() { - const { auth, challenges } = this.props; - - const buckets = getBuckets(_.get(auth.user, 'handle')); - - const getBucket = bucket => ( - - ); - - return ( -
- {auth.user ? getBucket(BUCKETS.MY) : null} - {getBucket(BUCKETS.OPEN_FOR_REGISTRATION)} - {getBucket(BUCKETS.ONGOING)} - {getBucket(BUCKETS.PAST)} -
- ); - - /* - const filterChallengesStore = filterFilterChallengesStore( - this.state.filterChallengesStore, - currentFilter, - ); - - return ( -
- { - Object.keys(filterChallengesStore).map((filterName) => { - let expansionButtion; - const unfilteredChallenges = filterChallengesStore[filterName]; - const filteredChallenges = _.sortBy(_.filter(unfilteredChallenges, additionalFilter), - sortingFunctionStore[filterSortingStore[filterName]]); - let initialChallenges = unfilteredChallenges; - - const challengeCountTotal = filterTotalCountStore[filterName]; - const trimmedFilterName = filterName.replace(/\s+/g, '-').toLowerCase(); - const filter = findFilterByName(filterName, filters); - const { sortingOptions } = filter; - const { length: filteredChallengeNumber } = filteredChallenges; - const { length: unFilteredChallengeNumber } = unfilteredChallenges; - const challengeCategoryExpandable = isChallengeCategoryExpandable({ - initialNumberToShow, - filteredChallengeNumber, - unFilteredChallengeNumber, - challengeCountTotal, - }); - - if (!expanded) initialChallenges = filteredChallenges.slice(0, initialNumberToShow); - if (!expanded && challengeCategoryExpandable) { - expansionButtion = getExpandBucketButton( - () => this.onExpandFilterResult(filterName), - style['view-more'], - ); - } - - return ( -
- this.onSortingSelect(filterName, optionName)} - value={filterSortingStore[filterName]} - key={`${trimmedFilterName}-sorting-bar`} - /> - this.props.onTechTagClicked(tag), - )} - renderItemTemplate={getChallengeCardPlaceholder} - fetchItems={(pageIndex, pageSize = 50) => { - const f = {}; - if (filter.filteringParams.status) { - f.status = filter.filteringParams.status; - } - if (this.props.challengeGroupId) { - f.groupIds = this.props.challengeGroupId; - } - const fm = _.clone(f); - if (fm.status === 'completed') fm.status = 'past'; - return Promise.all([ - this.props.getChallenges(f, { - limit: pageSize, - offset: pageIndex * pageSize, - }, - this.props.auth.tokenV3, - undefined, - filter.filteringParams.user ? - this.props.auth.user.handle && this.props.auth.user : - undefined).then(res => - res.challenges.map(i => normalizeChallenge(i)), - ), - this.props.getMarathonMatches(f, { - limit: pageSize, - offset: pageIndex * pageSize, - }, - this.props.auth.tokenV3, - undefined, - filter.filteringParams.user && this.props.auth.user ? - this.props.auth.user.handle : undefined).then(res => - res.challenges.map(i => normalizeMarathonMatch(i)), - ), - ]).then(([a, b]) => a.concat(b)); - }} - batchNumber={batchLoadNumber} - filter={additionalFilter} - tempDataFilter={filterName} - sort={sortingFunctionStore[filterSortingStore[filterName]]} - uniqueIdentifier={challengeUniqueIdentifier} - /> - {expansionButtion} -
- ); - }) - } -
- ); - */ - // } -// } - ChallengeCardContainer.defaultProps = { challengeGroupId: '', onTechTagClicked: _.noop, @@ -331,33 +111,13 @@ ChallengeCardContainer.propTypes = { handle: PT.string, }), }).isRequired, - // challengeGroupId: PT.string, - // onTechTagClicked: PT.func, - // onExpandFilterResult: PT.func, - // additionalFilter: PT.func, challenges: PT.arrayOf(PT.shape()), - loadMore: PT.shape({}).isRequired, + loadingDraftChallenges: PT.bool.isRequired, + loadingPastChallenges: PT.bool.isRequired, + loadMoreDraft: PT.func.isRequired, loadMorePast: PT.func.isRequired, selectBucket: PT.func.isRequired, setFilterState: PT.func.isRequired, setSort: PT.func.isRequired, sorts: PT.shape().isRequired, - - /* - currentFilterName: PT.string, - - filters: PT.arrayOf(PT.shape({ - check: PT.func, - name: PT.string, - getApiUrl: PT.func, - sortingOptions: PT.arrayOf(PT.string), - allIncluded: PT.bool, - info: PT.shape(), - })), - */ - // expanded: PT.oneOfType([PT.bool, PT.string]), - // getChallenges: PT.func.isRequired, - // getMarathonMatches: PT.func.isRequired, }; - -// export default ChallengeCardContainer; diff --git a/src/shared/components/challenge-listing/index.jsx b/src/shared/components/challenge-listing/index.jsx index 17148c4c64..2f1964e70f 100755 --- a/src/shared/components/challenge-listing/index.jsx +++ b/src/shared/components/challenge-listing/index.jsx @@ -1,28 +1,15 @@ -/* global JSON */ - /** - * This component implements a demo of ChallengeFilters in action. - * - * It uses ChallengeFilters component to show the challenge search & filter panel, - * and it implements a simple logic to search, filter, and display the challenges - * using TC API V2. As TC API V2 does not really provides the necessary ways to - * filter and search the challenges, this example component always query all - * challenges from the queried competition tracks (Data Science, Design, or - * Development), and then performs the filtering of the results at the front-end - * side, achieving the same behavior, visible for the end-user, as was requested in - * the related challenge. + * Challenge listing component. */ import _ from 'lodash'; import ChallengeFilters from 'containers/challenge-listing/FilterPanel'; import React from 'react'; import PT from 'prop-types'; -// import config from 'utils/config'; import Sticky from 'react-stickynode'; import * as Filter from 'utils/challenge-listing/filter'; import Sidebar from 'containers/challenge-listing/Sidebar'; -// import ChallengeCard from './ChallengeCard'; import Listing from './Listing'; import ChallengeCardPlaceholder from './placeholders/ChallengeCard'; import SRMCard from './SRMCard'; @@ -32,82 +19,12 @@ import './style.scss'; // Number of challenge placeholder card to display const CHALLENGE_PLACEHOLDER_COUNT = 8; -/** Fetch Past challenges - * {param} limit: Number of challenges to fetch - * {param} helper: Function to invoke to map response - */ -/* -function fetchPastChallenges(limit, helper, groupIds, tokenV3) { - const cService = getChallengesService(tokenV3); - const MAX_LIMIT = 50; - const result = []; - const numFetch = Math.ceil(limit / MAX_LIMIT); - const handleResponse = res => helper(res); - for (let i = 0; i < numFetch; i += 1) { - result.push(cService.getChallenges({ - groupIds, - status: 'COMPLETED', - }, { - limit: MAX_LIMIT, - offset: i * MAX_LIMIT, - }).then(handleResponse)); - } - return result; -} -*/ - -// helper function to serialize object to query string -// const serialize = filter => filter.getURLEncoded(); - -// helper function to de-serialize query string to filter object -/* -const deserialize = (queryString) => { - const filter = new SideBarFilter({ - filter: queryString, - isSavedFilter: true, // So that we can reuse constructor for deserializing - }); - if (!_.values(SideBarFilterModes).includes(filter.name)) { - filter.isCustomFilter = true; - } - return filter; -}; -*/ - -// The demo component itself. export default function ChallengeListing(props) { let challenges = props.challenges; - /* - if (this.props.challengeGroupId) { - challenges = challenges.filter(item => - item.groups[this.props.challengeGroupId]); - } - */ - - // filter all challenges by master filter before applying any user filters - /* - challenges = _.filter(challenges, this.props.masterFilterFunc); - const currentFilter = this.getFilter(); - currentFilter.mode = 'custom'; - if (this.props.auth.user) { - challenges = challenges.map((item) => { - if (item.users[this.props.auth.user.handle]) { - _.assign(item, { myChallenge: true }); - } - return item; - }); - } - */ challenges = challenges.filter( Filter.getFilterFunction(props.filterState)); - // challenges.sort((a, b) => b.submissionEndDate - a.submissionEndDate); - - // const filter = this.getFilter(); - // const { name: sidebarFilterName } = filter; - - // const expanded = sidebarFilterName !== 'All Challenges'; - const expanded = false; let challengeCardContainer; @@ -126,69 +43,22 @@ export default function ChallengeListing(props) { { - _.noop(tag); - /* TODO: This should be rewired using setFilterState(..) */ - // if (this.challengeFilters) this.challengeFilters.setKeywords(tag); - }} challenges={challenges} - loadMore={props.loadMore} + loadingDraftChallenges={props.loadingDraftChallenges} + loadingPastChallenges={props.loadingPastChallenges} + loadMoreDraft={props.loadMoreDraft} loadMorePast={props.loadMorePast} selectBucket={props.selectBucket} setFilterState={props.setFilterState} setSort={props.setSort} sorts={props.sorts} - - // challengeGroupId={this.props.challengeGroupId} - // currentFilterName={sidebarFilterName} - // expanded={sidebarFilterName !== 'All Challenges'} - // getChallenges={this.props.getChallenges} - // getMarathonMatches={this.props.getMarathonMatches} - /* - additionalFilter={ - challenge => filterFunc(challenge) && sidebarFilterFunc(challenge) - } - // Handle onExpandFilterResult to update the sidebar - onExpandFilterResult={ - filterName => this.sidebar.selectFilterWithName(filterName) - } - */ /> ); } - // Upcoming srms - // let futureSRMChallenge = this.state.srmChallenges.filter(challenge => - // challenge.status === 'FUTURE'); - /* - futureSRMChallenge = futureSRMChallenge.sort( - (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), - ); - - const UpcomingSrm = futureSRMChallenge.map( - srmChallenge => ( - - ), - ); - */ - return (
{ - /* - if (this.sidebar) { - const f = (new SideBarFilter(SideBarFilterModes.CUSTOM)).merge(filterToSave); - f.name = this.sidebar.getAvailableFilterName(); - this.sidebar.addFilter(f); - } - */ - } challengeGroupId={props.challengeGroupId} communityName={props.communityName} setCardType={_.noop/* cardType => this.setCardType(cardType) */} @@ -243,6 +113,8 @@ export default function ChallengeListing(props) { ChallengeListing.defaultProps = { challengeGroupId: '', communityName: null, + loadMoreDraft: null, + loadMorePast: null, masterFilterFunc: () => true, auth: null, }; @@ -252,17 +124,15 @@ ChallengeListing.propTypes = { challenges: PT.arrayOf(PT.shape()).isRequired, communityName: PT.string, filterState: PT.shape().isRequired, - // getChallenges: PT.func.isRequired, - // getMarathonMatches: PT.func.isRequired, loadingChallenges: PT.bool.isRequired, - loadMorePast: PT.func.isRequired, - loadMore: PT.shape().isRequired, + loadingDraftChallenges: PT.bool.isRequired, + loadingPastChallenges: PT.bool.isRequired, + loadMoreDraft: PT.func, + loadMorePast: PT.func, selectBucket: PT.func.isRequired, setFilterState: PT.func.isRequired, setSort: PT.func.isRequired, sorts: PT.shape().isRequired, - challengeGroupId: PT.string, - // masterFilterFunc: PT.func, auth: PT.shape(), }; diff --git a/src/shared/containers/challenge-listing/Listing/index.jsx b/src/shared/containers/challenge-listing/Listing/index.jsx index 1b34207a11..8a5588531f 100644 --- a/src/shared/containers/challenge-listing/Listing/index.jsx +++ b/src/shared/containers/challenge-listing/Listing/index.jsx @@ -9,39 +9,24 @@ * which is used to define which challenges should be listed for the certain community. */ -import _ from 'lodash'; +// import _ from 'lodash'; import actions from 'actions/challenge-listing'; import filterPanelActions from 'actions/challenge-listing/filter-panel'; import headerActions from 'actions/topcoder_header'; import logger from 'utils/logger'; import React from 'react'; import PT from 'prop-types'; +import shortid from 'shortid'; import { connect } from 'react-redux'; import ChallengeListing from 'components/challenge-listing'; import Banner from 'components/tc-communities/Banner'; import NewsletterSignup from 'components/tc-communities/NewsletterSignup'; -import shortid from 'shortid'; import sidebarActions from 'actions/challenge-listing/sidebar'; import { BUCKETS } from 'utils/challenge-listing/buckets'; import style from './styles.scss'; -// helper function to de-serialize query string to filter object -/* -const deserialize = (queryString) => { - const filter = new SideBarFilter({ - filter: queryString, - isSavedFilter: true, // So that we can reuse constructor for deserializing - }); - if (!_.values(SideBarFilterModes).includes(filter.name)) { - filter.isCustomFilter = true; - } - return filter; -}; -*/ - let mounted = false; -// The container component class ListingContainer extends React.Component { constructor(props) { @@ -56,26 +41,17 @@ class ListingContainer extends React.Component { logger.error('Attempt to mount multiple instances of ChallengeListingPageContainer at the same time!'); } else mounted = true; this.loadChallenges(); - - /* LOAD FILTER FROM URL, IF NECESSARY! */ - - /* Get filter from the URL hash, if necessary. */ - /* - const filter = this.props.location.hash.slice(1); - if (filter && filter !== this.props.filter) { - // this.props.setFilter(filter); - } else if (this.props.challengeGroupId) { - const f = deserialize(this.props.filter); - f.groupId = this.props.challengeGroupId; - // this.props.setFilter(f.getURLEncoded()); - } - */ } componentDidUpdate(prevProps) { const token = this.props.auth.tokenV3; - if (token && token !== prevProps.auth.tokenV3) { - setImmediate(() => this.loadChallenges()); + if (token) { + if (!prevProps.auth.tokenV3) setImmediate(() => this.loadChallenges()); + } else if (prevProps.auth.tokenV3) { + setImmediate(() => { + this.props.dropChallenges(); + this.loadChallenges(); + }); } } @@ -87,46 +63,9 @@ class ListingContainer extends React.Component { } loadChallenges() { - const { tokenV3, user } = this.props.auth; - - /* Gets all active challenges. */ - this.props.getAllChallenges({ status: 'ACTIVE' }, tokenV3); - this.props.getAllMarathonMatches({ status: 'ACTIVE' }, tokenV3); - - /* Gets all active challenges, where the vistor is participant. */ - if (user) { - this.props.getAllChallenges({ - status: 'ACTIVE', - }, tokenV3, null, user.handle); - this.props.getAllMarathonMatches({ - status: 'ACTIVE', - }, tokenV3, null, user.handle); - } - - /* Gets some (50 + 50) past challenges and MMs. */ - this.props.getChallenges({ status: 'COMPLETED' }, { limit: 50 }, tokenV3); - this.props.getMarathonMatches({ status: 'PAST' }, { limit: 50 }, tokenV3); - - /* Gets some (50 + 50) upcoming challenges and MMs. */ - this.props.getChallenges({ status: 'DRAFT' }, { limit: 50 }, tokenV3); - this.props.getMarathonMatches({ status: 'DRAFT' }, { limit: 50 }, tokenV3); - } - - loadMorePast() { - const { tokenV3 } = this.props.auth; - const { nextPage } = this.props.loadMore.past; - this.props.setLoadMore('past', { - loading: true, - }); - console.log('LOAD MORE PAST CHALLENGES!'); - Promise.all([ - this.props.getChallenges({ - status: 'COMPLETED', - }, { limit: 50, offset: 50 * nextPage }, tokenV3), - this.props.getMarathonMatches({ - status: 'PAST', - }, { limit: 50, offset: 50 * nextPage }, tokenV3), - ]).then(() => console.log('READY!')); + this.props.getAllActiveChallenges(this.props.auth.tokenV3); + this.props.getDraftChallenges(0, this.props.auth.tokenV3); + this.props.getPastChallenges(0, this.props.auth.tokenV3); } /** @@ -159,14 +98,36 @@ class ListingContainer extends React.Component { render() { const { + auth: { + tokenV3, + }, + allDraftChallengesLoaded, + allPastChallengesLoaded, activeBucket, challenges, challengeSubtracks, challengeTags, challengeGroupId, + getDraftChallenges, + getPastChallenges, + lastRequestedPageOfDraftChallenges, + lastRequestedPageOfPastChallenges, listingOnly, selectBucket, } = this.props; + + let loadMoreDraft; + if (!allDraftChallengesLoaded) { + loadMoreDraft = () => + getDraftChallenges(1 + lastRequestedPageOfDraftChallenges, tokenV3); + } + + let loadMorePast; + if (!allPastChallengesLoaded) { + loadMorePast = () => + getPastChallenges(1 + lastRequestedPageOfPastChallenges, tokenV3); + } + return (
{/* For demo we hardcode banner properties so we can disable max-len linting */} @@ -192,12 +153,12 @@ class ListingContainer extends React.Component { challengeTags={challengeTags} communityName={this.props.communityName} filterState={this.props.filter} - getChallenges={this.props.getChallenges} - getMarathonMatches={this.props.getMarathonMatches} - loadingChallenges={Boolean(_.keys(this.props.pendingRequests).length)} + loadingChallenges={Boolean(this.props.loadingActiveChallengesUUID)} + loadingDraftChallenges={Boolean(this.props.loadingDraftChallengesUUID)} + loadingPastChallenges={Boolean(this.props.loadingPastChallengesUUID)} selectBucket={selectBucket} - loadMore={this.props.loadMore} - loadMorePast={() => this.loadMorePast()} + loadMoreDraft={loadMoreDraft} + loadMorePast={loadMorePast} setFilterState={(state) => { this.props.setFilter(state); this.props.setSearchText(state.text || ''); @@ -236,16 +197,26 @@ ListingContainer.defaultProps = { }; ListingContainer.propTypes = { + auth: PT.shape({ + tokenV3: PT.string, + user: PT.shape(), + }).isRequired, + allDraftChallengesLoaded: PT.bool.isRequired, + allPastChallengesLoaded: PT.bool.isRequired, challenges: PT.arrayOf(PT.shape({})).isRequired, challengeSubtracks: PT.arrayOf(PT.string).isRequired, challengeTags: PT.arrayOf(PT.string).isRequired, + dropChallenges: PT.func.isRequired, filter: PT.shape().isRequired, - pendingRequests: PT.shape().isRequired, communityName: PT.string, - getAllChallenges: PT.func.isRequired, - getAllMarathonMatches: PT.func.isRequired, - getChallenges: PT.func.isRequired, - getMarathonMatches: PT.func.isRequired, + getAllActiveChallenges: PT.func.isRequired, + getDraftChallenges: PT.func.isRequired, + getPastChallenges: PT.func.isRequired, + lastRequestedPageOfDraftChallenges: PT.number.isRequired, + lastRequestedPageOfPastChallenges: PT.number.isRequired, + loadingActiveChallengesUUID: PT.string.isRequired, + loadingDraftChallengesUUID: PT.string.isRequired, + loadingPastChallengesUUID: PT.string.isRequired, markHeaderMenu: PT.func.isRequired, selectBucket: PT.func.isRequired, setFilter: PT.func.isRequired, @@ -253,8 +224,6 @@ ListingContainer.propTypes = { sorts: PT.shape().isRequired, setSearchText: PT.func.isRequired, setSort: PT.func.isRequired, - setLoadMore: PT.func.isRequired, - loadMore: PT.shape().isRequired, /* OLD PROPS BELOW */ listingOnly: PT.bool, @@ -268,99 +237,54 @@ ListingContainer.propTypes = { location: PT.shape({ hash: PT.string, }).isRequired, - auth: PT.shape({ - tokenV3: PT.string, - user: PT.shape(), - }).isRequired, }; const mapStateToProps = (state) => { const cl = state.challengeListing; return { auth: state.auth, + allDraftChallengesLoaded: cl.allDraftChallengesLoaded, + allPastChallengesLoaded: cl.allPastChallengesLoaded, filter: cl.filter, challenges: cl.challenges, challengeSubtracks: cl.challengeSubtracks, challengeTags: cl.challengeTags, - counts: cl.counts, + lastRequestedPageOfDraftChallenges: cl.lastRequestedPageOfDraftChallenges, + lastRequestedPageOfPastChallenges: cl.lastRequestedPageOfPastChallenges, + loadingActiveChallengesUUID: cl.loadingActiveChallengesUUID, + loadingDraftChallengesUUID: cl.loadingDraftChallengesUUID, + loadingPastChallengesUUID: cl.loadingPastChallengesUUID, loadingChallengeSubtracks: cl.loadingChallengeSubtracks, loadingChallengeTags: cl.loadingChallengeTags, - loadMore: cl.loadMore, - oldestData: cl.oldestData, - pendingRequests: cl.pendingRequests, sorts: cl.sorts, activeBucket: cl.sidebar.activeBucket, }; }; -/** - * Loads into redux all challenges matching the request. - * @param {Function} dispatch - */ -function getAllChallenges(dispatch, ...rest) { - const uuid = shortid(); - dispatch(actions.challengeListing.getInit(uuid)); - dispatch(actions.challengeListing.getAllChallenges(uuid, ...rest)); -} - -/** - * Loads into redux all MMs matching the request. - * @param {Function} dispatch - */ -function getAllMarathonMatches(dispatch, ...rest) { - const uuid = shortid(); - dispatch(actions.challengeListing.getInit(uuid)); - dispatch(actions.challengeListing.getAllMarathonMatches(uuid, ...rest)); -} - -/** - * Callback for loading challenges satisfying to the specified criteria. - * All arguments starting from second should match corresponding arguments - * of the getChallenges action. - * @param {Function} dispatch - */ -function getChallenges(dispatch, ...rest) { - const uuid = shortid(); - dispatch(actions.challengeListing.getInit(uuid)); - const action = actions.challengeListing.getChallenges(uuid, ...rest); - dispatch(action); - return action.payload; -} - -/** - * Callback for loading marathon matches satisfying to the specified criteria. - * All arguments starting from second should match corresponding arguments - * of the getChallenges action. - * @param {Function} dispatch - */ -function getMarathonMatches(dispatch, filters, ...rest) { - const uuid = shortid(); - dispatch(actions.challengeListing.getInit(uuid)); - const f = _.clone(filters); - if (f.status === 'COMPLETED') f.status = 'PAST'; - const action = actions.challengeListing.getMarathonMatches(uuid, f, ...rest); - dispatch(action); - // TODO: This is hack to make the Redux loading of challenges to work - // with older code inside the InfiniteList, until it is properly - // refactored. - return action.payload; -} - function mapDispatchToProps(dispatch) { const a = actions.challengeListing; const ah = headerActions.topcoderHeader; const fpa = filterPanelActions.challengeListing.filterPanel; const sa = sidebarActions.challengeListing.sidebar; return { - getAllChallenges: (...rest) => getAllChallenges(dispatch, ...rest), - getAllMarathonMatches: (...rest) => - getAllMarathonMatches(dispatch, ...rest), - getChallenges: (...rest) => getChallenges(dispatch, ...rest), - getMarathonMatches: (...rest) => getMarathonMatches(dispatch, ...rest), - reset: () => dispatch(a.reset()), + dropChallenges: () => dispatch(a.dropChallenges()), + getAllActiveChallenges: (token) => { + const uuid = shortid(); + dispatch(a.getAllActiveChallengesInit(uuid)); + dispatch(a.getAllActiveChallengesDone(uuid, token)); + }, + getDraftChallenges: (page, token) => { + const uuid = shortid(); + dispatch(a.getDraftChallengesInit(uuid, page)); + dispatch(a.getDraftChallengesDone(uuid, page, token)); + }, + getPastChallenges: (page, token) => { + const uuid = shortid(); + dispatch(a.getPastChallengesInit(uuid, page)); + dispatch(a.getPastChallengesDone(uuid, page, token)); + }, selectBucket: bucket => dispatch(sa.selectBucket(bucket)), setFilter: state => dispatch(a.setFilter(state)), - setLoadMore: (...rest) => dispatch(a.setLoadMore(...rest)), setSearchText: text => dispatch(fpa.setSearchText(text)), setSort: (bucket, sort) => dispatch(a.setSort(bucket, sort)), markHeaderMenu: () => diff --git a/src/shared/reducers/challenge-listing/index.js b/src/shared/reducers/challenge-listing/index.js index 3550dea30a..d7e49563c0 100644 --- a/src/shared/reducers/challenge-listing/index.js +++ b/src/shared/reducers/challenge-listing/index.js @@ -1,33 +1,5 @@ /** * Reducer for state.challengeListing. - * - * This section of the state holds the following fields: - * - * - challenges {Array} - Normalized challenge objects loaded from the - * backend. Normalization means that we add extra fields to the challenge - * and marathon match objects, received from different backend endpoints, - * to ensure that the rest of the code can handle them in a uniform way, - * and that we are able to filter them in all necessary ways (e.g., when - * we fetch challenges belonging to a specific group, returned challenge - * objects do not have group information; so we append group ids to these - * objects to be able to filter them out by groups on the frontend side); - * - * - counts {Object} - Total counts of challenges in the backend. This object - * holds key - value pairs, where the key specifies a category and value - * specifies the total count of challenges under that category; - * - * - oldestData {Number} - Timestamp of the moment of reducer construction, - * or of the last reset of this section of the state. This allows to - * automatically refresh loaded challenge data from time to time to - * ensure that displayed data are in reasonable sync with the backend. - * - * - pendingRequests {Object} - Set of UUIDs of pending requests to - * load challenge data. It allows - * 1. To see that we are loading something right now; - * 2. To cancel pending requests (reducer will ignore resolved actions - * with UUID not present in this set at the time of resolution). - * Technically it is stored as a map of UUIDs to boolean values, - * as only plain objects can be safely stored into Redux state. */ /* global window */ @@ -37,86 +9,38 @@ import actions from 'actions/challenge-listing'; import logger from 'utils/logger'; import qs from 'qs'; import { handleActions } from 'redux-actions'; -import { COMPETITION_TRACKS } from 'utils/tc'; import { combine } from 'utils/redux'; import filterPanel from '../challenge-listing/filter-panel'; import sidebar from '../challenge-listing/sidebar'; -/** - * Normalizes a regular challenge object received from the backend. - * NOTE: This function is copied from the existing code in the challenge listing - * component. It is possible, that this normalization is not necessary after we - * have moved to Topcoder API v3, but it is kept for now to minimize a risk of - * breaking anything. - * @param {Object} challenge Challenge object received from the backend. - * @return {Object} Normalized challenge. - */ -export function normalizeChallenge(challenge) { - const registrationOpen = challenge.allPhases.filter(d => - d.phaseType === 'Registration', - )[0].phaseStatus === 'Open' ? 'Yes' : 'No'; - const groups = {}; - if (challenge.groupIds) { - challenge.groupIds.forEach((id) => { - groups[id] = true; - }); +function onGetAllActiveChallengesDone(state, { error, payload }) { + if (error) { + logger.error(payload); + return state; } - return _.defaults(_.clone(challenge), { - communities: new Set([COMPETITION_TRACKS[challenge.track]]), - groups, - platforms: '', - registrationOpen, - technologies: '', - submissionEndTimestamp: challenge.submissionEndDate, - }); + const { uuid, challenges: loaded } = payload; + if (uuid !== state.loadingActiveChallengesUUID) return state; + + /* Once all active challenges are fetched from the API, we remove from the + * store any active challenges stored there previously, and also any + * challenges with IDs matching any challenges loaded now as active. */ + const ids = new Set(); + loaded.forEach(item => ids.add(item.id)); + const challenges = state.challenges + .filter(item => item.status !== 'ACTIVE' && !ids.has(item.id)) + .concat(loaded); + + return { + ...state, + challenges, + lastUpdateOfActiveChallenges: Date.now(), + loadingActiveChallengesUUID: '', + }; } -/** - * Normalizes a marathon match challenge object received from the backend. - * NOTE: This function is copied from the existing code in the challenge listing - * component. It is possible, that this normalization is not necessary after we - * have moved to Topcoder API v3, but it is kept for now to minimize a risk of - * breaking anything. - * @param {Object} challenge MM challenge object received from the backend. - * @return {Object} Normalized challenge. - */ -export function normalizeMarathonMatch(challenge) { - const endTimestamp = new Date(challenge.endDate).getTime(); - const allphases = [{ - challengeId: challenge.id, - phaseType: 'Registration', - phaseStatus: endTimestamp > Date.now() ? 'Open' : 'Close', - scheduledEndTime: challenge.endDate, - }]; - const groups = {}; - if (challenge.groupIds) { - challenge.groupIds.forEach((id) => { - groups[id] = true; - }); - } - return _.defaults(_.clone(challenge), { - challengeCommunity: 'Data', - challengeType: 'Marathon', - allPhases: allphases, - currentPhases: allphases.filter(phase => phase.phaseStatus === 'Open'), - communities: new Set([COMPETITION_TRACKS.DATA_SCIENCE]), - currentPhaseName: endTimestamp > Date.now() ? 'Registration' : '', - groups, - numRegistrants: challenge.numRegistrants ? challenge.numRegistrants[0] : 0, - numSubmissions: challenge.userIds ? challenge.userIds.length : 0, - platforms: '', - prizes: [0], - registrationOpen: endTimestamp > Date.now() ? 'Yes' : 'No', - registrationStartDate: challenge.startDate, - submissionEndDate: challenge.endDate, - submissionEndTimestamp: endTimestamp, - technologies: '', - totalPrize: 0, - track: 'DATA_SCIENCE', - status: endTimestamp > Date.now() ? 'ACTIVE' : 'COMPLETED', - subTrack: 'MARATHON_MATCH', - }); +function onGetAllActiveChallengesInit(state, { payload }) { + return { ...state, loadingActiveChallengesUUID: payload }; } /** @@ -149,127 +73,74 @@ function onGetChallengeTagsDone(state, action) { }; } -/** - * Commong handling of all get challenge / get marathon matches actions. - * This function merges already normalized data into the array of loaded - * challenges. - * @param {Object} state - * @param {Object} action - * @param {Function} normalizer A function to use for normalization of - * challenges contained in the payload to the common format expected by - * the frontend. - * @return {Object} New state. - */ -function onGetDone(state, { payload }, normalizer) { - /* Tests whether we should accept the result of this action, and removes - UUID of this action from the set of pending actions. */ - if (!state.pendingRequests[payload.uuid]) return state; - const pendingRequests = _.clone(state.pendingRequests); - delete pendingRequests[payload.uuid]; - - /* In case the payload holds a total count for some category of challenges, - we write this count into the state, probably overwritting the old value. */ - let counts = state.counts; - if (payload.totalCount) { - counts = { - ...counts, - [payload.totalCount.category]: payload.totalCount.value || 0, - }; - } +function onGetDraftChallengesInit(state, { payload: { uuid, page } }) { + return { + ...state, + lastRequestedPageOfDraftChallenges: page, + loadingDraftChallengesUUID: uuid, + }; +} - if (!payload.challenges || !payload.challenges.length) { - return { - ...state, - counts, - pendingRequests, - }; +function onGetDraftChallengesDone(state, { error, payload }) { + if (error) { + logger.error(payload); + return state; } + const { uuid, challenges: loaded } = payload; + if (uuid !== state.loadingDraftChallengesUUID) return state; + + const ids = new Set(); + loaded.forEach(item => ids.add(item.id)); - /* Merge of the known and received challenge data. First of all we reduce - the array of already loaded challenges to the map, to allow efficient - lookup. */ - const challenges = {}; - state.challenges.forEach((item) => { - challenges[item.id] = item; - }); - payload.challenges.forEach((item) => { - const it = _.defaults(normalizer(item), { - users: {}, - }); - - /* Similarly it happens with users participating in the challenges. */ - if (payload.user) it.users[payload.user] = true; - - /* If we already had some data about this challenge loaded, we should - properly merge-in the known information about groups and users. */ - const old = challenges[it.id]; - if (old) _.merge(it.users, old.users); - - challenges[it.id] = it; - }); + /* Fetching 0 page of draft challenges also drops any draft challenges + * loaded to the state before. */ + const filter = state.lastRequestedPageOfDraftChallenges + ? item => !ids.has(item.id) + : item => !ids.has(item.id) && item.status !== 'DRAFT'; + + const challenges = state.challenges + .filter(filter).concat(loaded); return { ...state, - challenges: _.toArray(challenges), - counts, - pendingRequests, + allDraftChallengesLoaded: loaded.length === 0, + challenges, + loadingDraftChallengesUUID: '', }; } -/** - * Handles the CHALLENGE_LISTING/GET_INIT action. - * @param {Object} state - * @param {Object} action - * @return {Object} New state. - */ -function onGetInit(state, { payload }) { - if (state.pendingRequests[payload]) { - throw new Error('Request UUID is not unique.'); - } +function onGetPastChallengesInit(state, { payload: { uuid, page } }) { return { ...state, - pendingRequests: { - ...state.pendingRequests, - [payload]: true, - }, + lastRequestedPageOfPastChallenges: page, + loadingPastChallengesUUID: uuid, }; } -/** - * Handling of CHALLENGE_LISTING/GET_CHALLENGES - * and CHALLENGE_LISTING/GET_USER_CHALLENGES actions. - * @param {Object} state - * @param {Object} action - * @return {Object} New state. - */ -function onGetChallenges(state, action) { - return onGetDone(state, action, normalizeChallenge); -} +function onGetPastChallengesDone(state, { error, payload }) { + if (error) { + logger.error(payload); + return state; + } + const { uuid, challenges: loaded } = payload; + if (uuid !== state.loadingPastChallengesUUID) return state; -/** - * Handling of CHALLENGE_LISTING/GET_MARATHON_MATCHES - * and CHALLENGE_LISTING/GET_USER_MARATHON_MATCHES actions. - * @param {Object} state - * @param {Object} action - * @return {Object} New state. - */ -function onGetMarathonMatches(state, action) { - return onGetDone(state, action, normalizeMarathonMatch); -} + const ids = new Set(); + loaded.forEach(item => ids.add(item.id)); + + /* Fetching 0 page of past challenges also drops any past challenges + * loaded to the state before. */ + const filter = state.lastRequestedPageOfPastChallenges + ? item => !ids.has(item.id) + : item => !ids.has(item.id) && item.status !== 'COMPLETED' && item.status !== 'PAST'; + + const challenges = state.challenges.filter(filter).concat(loaded); -/** - * Cleans received data from the state, and cancels any pending requests to - * fetch challenges. It does not reset total counts of challenges, as they - * are anyway will be overwritten with up-to-date values. - * @param {Object} state Previous state. - * @return {Object} New state. - */ -function onReset(state) { return { ...state, - challenges: [], - oldestData: Date.now(), - pendingRequests: {}, + allPastChallengesLoaded: loaded.length === 0, + challenges, + loadingPastChallengesUUID: '', }; } @@ -300,22 +171,40 @@ function onSetFilter(state, { payload }) { function create(initialState) { const a = actions.challengeListing; return handleActions({ - [a.getAllChallenges]: onGetChallenges, - [a.getAllMarathonMatches]: onGetMarathonMatches, - [a.getChallengeSubtracksDone]: onGetChallengeSubtracksDone, + [a.dropChallenges]: state => ({ + ...state, + allDraftChallengesLoaded: false, + allPastChallengesLoaded: false, + challenges: [], + lastRequestedPageOfDraftChallenges: -1, + lastRequestedPageOfPastChallenges: -1, + lastUpdateOfActiveChallenges: 0, + loadingActiveChallengesUUID: '', + loadingDraftChallengesUUID: '', + loadingPastChallengesUUID: '', + }), + + [a.getAllActiveChallengesInit]: onGetAllActiveChallengesInit, + [a.getAllActiveChallengesDone]: onGetAllActiveChallengesDone, + [a.getChallengeSubtracksInit]: state => ({ ...state, loadingChallengeSubtracks: true, }), - [a.getChallengeTagsDone]: onGetChallengeTagsDone, + [a.getChallengeSubtracksDone]: onGetChallengeSubtracksDone, + [a.getChallengeTagsInit]: state => ({ ...state, loadingChallengeTags: true, }), - [a.getChallenges]: onGetChallenges, - [a.getInit]: onGetInit, - [a.getMarathonMatches]: onGetMarathonMatches, - [a.reset]: onReset, + [a.getChallengeTagsDone]: onGetChallengeTagsDone, + + [a.getDraftChallengesInit]: onGetDraftChallengesInit, + [a.getDraftChallengesDone]: onGetDraftChallengesDone, + + [a.getPastChallengesInit]: onGetPastChallengesInit, + [a.getPastChallengesDone]: onGetPastChallengesDone, + [a.setFilter]: onSetFilter, [a.setSort]: (state, { payload }) => ({ ...state, @@ -324,33 +213,26 @@ function create(initialState) { [payload.bucket]: payload.sort, }, }), - [a.setLoadMore]: (state, { payload }) => ({ - ...state, - loadMore: { - ...state.loadMore, - [payload.key]: payload.data, - }, - }), }, _.defaults(_.clone(initialState) || {}, { + allDraftChallengesLoaded: false, + allPastChallengesLoaded: false, + challenges: [], challengeSubtracks: [], challengeTags: [], - counts: {}, filter: {}, + + lastRequestedPageOfDraftChallenges: -1, + lastRequestedPageOfPastChallenges: -1, + lastUpdateOfActiveChallenges: 0, + + loadingActiveChallengesUUID: '', + loadingDraftChallengesUUID: '', + loadingPastChallengesUUID: '', + loadingChallengeSubtracks: false, loadingChallengeTags: false, - loadMore: { - past: { - loading: false, - nextPage: 1, - }, - upcoming: { - loading: false, - nextPage: 1, - }, - }, - oldestData: Date.now(), - pendingRequests: {}, + sorts: {}, })); } diff --git a/src/shared/services/challenges.js b/src/shared/services/challenges.js index 38d03393f0..6ce76759eb 100644 --- a/src/shared/services/challenges.js +++ b/src/shared/services/challenges.js @@ -3,13 +3,95 @@ * challenges via TC API. */ +import _ from 'lodash'; import qs from 'qs'; +import { COMPETITION_TRACKS } from 'utils/tc'; import { getApiV2, getApiV3 } from './api'; export const ORDER_BY = { SUBMISSION_END_DATE: 'submissionEndDate', }; +/** + * Normalizes a regular challenge object received from the backend. + * NOTE: This function is copied from the existing code in the challenge listing + * component. It is possible, that this normalization is not necessary after we + * have moved to Topcoder API v3, but it is kept for now to minimize a risk of + * breaking anything. + * @param {Object} challenge Challenge object received from the backend. + * @param {String} username Optional. + * @return {Object} Normalized challenge. + */ +export function normalizeChallenge(challenge, username) { + const registrationOpen = challenge.allPhases.filter(d => + d.phaseType === 'Registration', + )[0].phaseStatus === 'Open' ? 'Yes' : 'No'; + const groups = {}; + if (challenge.groupIds) { + challenge.groupIds.forEach((id) => { + groups[id] = true; + }); + } + _.defaults(challenge, { + communities: new Set([COMPETITION_TRACKS[challenge.track]]), + groups, + platforms: '', + registrationOpen, + technologies: '', + submissionEndTimestamp: challenge.submissionEndDate, + users: username ? { username: true } : {}, + }); +} + +/** + * Normalizes a marathon match challenge object received from the backend. + * NOTE: This function is copied from the existing code in the challenge listing + * component. It is possible, that this normalization is not necessary after we + * have moved to Topcoder API v3, but it is kept for now to minimize a risk of + * breaking anything. + * @param {Object} challenge MM challenge object received from the backend. + * @param {String} username Optional. + * @return {Object} Normalized challenge. + */ +export function normalizeMarathonMatch(challenge, username) { + const endTimestamp = new Date(challenge.endDate).getTime(); + const allphases = [{ + challengeId: challenge.id, + phaseType: 'Registration', + phaseStatus: endTimestamp > Date.now() ? 'Open' : 'Close', + scheduledEndTime: challenge.endDate, + }]; + const groups = {}; + if (challenge.groupIds) { + challenge.groupIds.forEach((id) => { + groups[id] = true; + }); + } + _.defaults(challenge, { + challengeCommunity: 'Data', + challengeType: 'Marathon', + allPhases: allphases, + currentPhases: allphases.filter(phase => phase.phaseStatus === 'Open'), + communities: new Set([COMPETITION_TRACKS.DATA_SCIENCE]), + currentPhaseName: endTimestamp > Date.now() ? 'Registration' : '', + groups, + numRegistrants: challenge.numRegistrants ? challenge.numRegistrants[0] : 0, + numSubmissions: challenge.userIds ? challenge.userIds.length : 0, + platforms: '', + prizes: [0], + registrationOpen: endTimestamp > Date.now() ? 'Yes' : 'No', + registrationStartDate: challenge.startDate, + submissionEndDate: challenge.endDate, + submissionEndTimestamp: endTimestamp, + technologies: '', + totalPrize: 0, + track: 'DATA_SCIENCE', + status: endTimestamp > Date.now() ? 'ACTIVE' : 'COMPLETED', + subTrack: 'MARATHON_MATCH', + users: username ? { username: true } : {}, + }); +} + class ChallengesService { /** @@ -38,7 +120,7 @@ class ChallengesService { .then(res => (res.ok ? res.json() : new Error(res.statusText))) .then(res => ( res.result.status === 200 ? { - challenges: res.result.content, + challenges: res.result.content || [], totalCount: res.result.metadata.totalCount, } : new Error(res.result.content) )); @@ -86,7 +168,11 @@ class ChallengesService { * @return {Promise} Resolves to the api response. */ getChallenges(filters, params) { - return this.private.getChallenges('/challenges/', filters, params); + return this.private.getChallenges('/challenges/', filters, params) + .then((res) => { + res.challenges.forEach(item => normalizeChallenge(item)); + return res; + }); } /** @@ -96,7 +182,11 @@ class ChallengesService { * @return {Promise} Resolve to the api response. */ getMarathonMatches(filters, params) { - return this.private.getChallenges('/marathonMatches/', filters, params); + return this.private.getChallenges('/marathonMatches/', filters, params) + .then((res) => { + res.challenges.forEach(item => normalizeMarathonMatch(item)); + return res; + }); } /** @@ -108,7 +198,11 @@ class ChallengesService { */ getUserChallenges(username, filters, params) { const endpoint = `/members/${username.toLowerCase()}/challenges/`; - return this.private.getChallenges(endpoint, filters, params); + return this.private.getChallenges(endpoint, filters, params) + .then((res) => { + res.challenges.forEach(item => normalizeChallenge(item, username)); + return res; + }); } /** @@ -120,7 +214,11 @@ class ChallengesService { */ getUserMarathonMatches(username, filters, params) { const endpoint = `/members/${username.toLowerCase()}/mms/`; - return this.private.api.get(endpoint, filters, params); + return this.private.getChallenges(endpoint, filters, params) + .then((res) => { + res.challenges.forEach(item => normalizeMarathonMatch(item, username)); + return res; + }); } } diff --git a/src/shared/utils/challenge-listing/buckets.js b/src/shared/utils/challenge-listing/buckets.js index dc50904e2e..15f037a665 100644 --- a/src/shared/utils/challenge-listing/buckets.js +++ b/src/shared/utils/challenge-listing/buckets.js @@ -76,8 +76,7 @@ export function getBuckets(userHandle) { }, [BUCKETS.UPCOMING]: { filter: { - status: ['DRAFT'], - startDate: Date.now(), + upcoming: true, }, hideCount: true, name: 'Upcoming challenges', diff --git a/src/shared/utils/challenge-listing/filter.js b/src/shared/utils/challenge-listing/filter.js index d5cf3112d7..d7ef826960 100644 --- a/src/shared/utils/challenge-listing/filter.js +++ b/src/shared/utils/challenge-listing/filter.js @@ -40,6 +40,8 @@ * tracks {Object} - Permits only the challenges belonging to at least one of * the competition tracks presented as keys of this object. * + * upcoming {Boolean} - Permits only upcoming challenges. + * * users {Array} - Permits only the challenges where the specified (by handles) * users are participating. */ @@ -123,6 +125,11 @@ function filterByTrack(challenge, state) { return _.keys(state.tracks).some(track => challenge.communities.has(track)); } +function filterByUpcoming(challenge, state) { + if (_.isUndefined(state.upcoming)) return true; + return moment().isBefore(challenge.registrationStartDate); +} + function filterByUsers(challenge, state) { if (!state.users) return true; return state.users.find(user => challenge.users[user]); @@ -160,6 +167,7 @@ export function addTrack(state, track) { export function getFilterFunction(state) { return challenge => filterByStatus(challenge, state) && filterByTrack(challenge, state) + && filterByUpcoming(challenge, state) && filterByText(challenge, state) && filterByTags(challenge, state) && filterBySubtracks(challenge, state) From 0843a457c5b9196891df454bcf26fba5926b94b2 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Thu, 29 Jun 2017 14:25:30 +0200 Subject: [PATCH 19/20] Updates the way challenges for TC community are filtered Also includes community selector into all challenge listings (including outside the a community mini-site). --- docs/how-to-add-a-new-topcoder-community.md | 28 +++++++-- .../tc-communities/community-2/metadata.json | 6 +- .../tc-communities/demo-expert/metadata.json | 7 ++- .../example-theme-default/metadata.json | 1 + .../example-theme-green/metadata.json | 1 + .../example-theme-red/metadata.json | 1 + src/server/tc-communities/index.js | 32 ++++++++++ .../tc-communities/tc-prod-dev/metadata.json | 6 +- src/server/tc-communities/wipro/metadata.json | 6 +- src/shared/actions/challenge-listing/index.js | 25 ++++++++ .../Filters/ChallengeFilters.jsx | 9 +++ .../Filters/FiltersPanel/index.jsx | 61 +++++++------------ .../Listing/Bucket/index.jsx | 2 +- .../Sidebar/BucketSelector/index.jsx | 10 ++- .../challenge-listing/Sidebar/index.jsx | 3 + .../components/challenge-listing/index.jsx | 7 +++ .../challenge-listing/FilterPanel.jsx | 3 + .../challenge-listing/Listing/index.jsx | 30 +++++++-- .../containers/challenge-listing/Sidebar.jsx | 11 ++++ .../containers/tc-communities/Page/index.jsx | 1 + .../reducers/challenge-listing/index.js | 24 ++++++++ src/shared/reducers/tc-communities/meta.js | 2 +- src/shared/utils/challenge-listing/filter.js | 8 ++- 23 files changed, 224 insertions(+), 60 deletions(-) diff --git a/docs/how-to-add-a-new-topcoder-community.md b/docs/how-to-add-a-new-topcoder-community.md index 493d580bf5..84e246e596 100644 --- a/docs/how-to-add-a-new-topcoder-community.md +++ b/docs/how-to-add-a-new-topcoder-community.md @@ -10,8 +10,9 @@ To add a new community with the name **demo**, we should follow the following pr "authorizedGroupIds": [ "12345" ], - "challengeGroupId": "12345", - "challengeFilterTag": "", + "challengeFilter": { + "groupIds": ["12345"] + }, "communityId": "demo", "communitySelector": [{ "label": "Demo Community", @@ -25,6 +26,7 @@ To add a new community with the name **demo**, we should follow the following pr "redirect": "https://ios.topcoder.com/", "value": "3" }], + "groupId": "12345", "leaderboardApiUrl": "https://api.topcoder.com/v4/looks/0/run/json/", "logos": [ "/themes/demo/logo_topcoder_with_name.svg" @@ -49,10 +51,28 @@ To add a new community with the name **demo**, we should follow the following pr ``` Its fields serve the following purposes: - `authorizedGroupIds` - *String Array* - Optional. Array of group IDs. If specified, access to the community will be restricted only to authenticated visitors, included into, at least, one of the groups listed in this array. If undefined, community will be accessible to any visitors (including non-authenticated ones). - - `challengeGroupId` - *String* - Optional. ID of the group holding challenges related to this community. If undefined, challenge listing in this community will show all public challenges. - - `challengeFilterTag` - *String* - Optional. If specified, and not an empty string, only challenges having this technology tag will be shown inside the community (it acts as an additional filter after the group-based filtering). + - `challengeFilter` - *Object* - Challenge filter matching challenges related to the community. This object can include any options known to the `/src/utils/challenge-listing/filter.js` module, though in many cases you want to use just one of these: + ```js + /* Matches challenges belonging to any of the groups listed by ID. */ + { + "groupIds": ["12345"] + } + + /* Matches challenges tagged with at least one of the tags. */ + { + "tags": ["JavaScript"] + } + + /* Matches challenges belonging to any of the groups AND tagged with + * at least one of the tags. */ + { + "groupIds": ["12345"], + "tags": ["JavaScript"] + } + ``` - `communityId` - *String* - Unique ID of this community. - `communitySelector` - *Object Array* - Specifies data for the community selection dropdown inside the community header. Each object MUST HAVE `label` and `value` string fields, and MAY HAVE `redirect` field. If `redirect` field is specified, a click on that option in the dropdown will redirect user to the specified URL. + - `groupId` - *String* - This value of group ID is now used to fetch community statistics. Probably, it makes sense to use this value everywhere where `authorizedGroupIds` array is used, however, at the moment, these two are independent. - `leaderboardApiUrl` - *String* - Endpoint from where the leaderboard data should be loaded. - `logo` - *String Array* - Array of image URLs to insert as logos into the left corner of community's header. - `menuItems` - *Object Array* - Specifies options for the community navigation menu (both in the header and footer). Each object MUST HAVE `title` and `url` fields. For now, `url` field should be a relative link inside the community, within the same path segment. diff --git a/src/server/tc-communities/community-2/metadata.json b/src/server/tc-communities/community-2/metadata.json index d8ad473d16..11d838b402 100644 --- a/src/server/tc-communities/community-2/metadata.json +++ b/src/server/tc-communities/community-2/metadata.json @@ -2,8 +2,9 @@ "authorizedGroupIds": [ "20000002" ], - "challengeGroupId": "20000002", - "challengeFilterTag": "", + "challengeFilter": { + "groupIds": ["20000002"] + }, "communityId": "community-2", "communityName": "Community 2", "communitySelector": [{ @@ -18,6 +19,7 @@ "redirect": "https://ios.topcoder.com/", "value": "3" }], + "groupId": "20000002", "leaderboardApiUrl": "https://api.topcoder.com/v4/looks/458/run/json/", "logos": [ "/themes/community-2/wipro-logo.png", diff --git a/src/server/tc-communities/demo-expert/metadata.json b/src/server/tc-communities/demo-expert/metadata.json index e43793e002..0a59c3f7d5 100644 --- a/src/server/tc-communities/demo-expert/metadata.json +++ b/src/server/tc-communities/demo-expert/metadata.json @@ -1,6 +1,8 @@ { - "challengeGroupId": "1001", - "challengeFilterTag": ".NET", + "challengeFilter": { + "groupIds": ["1001"], + "tags": [".NET"] + }, "communityId": "demo-expert", "communityName": "Demo Expert Community", "communitySelector": [{ @@ -15,6 +17,7 @@ "redirect": "https://ios.topcoder.com/", "value": "3" }], + "groupId": "1001", "logos": [ "/themes/demo-expert/logo_topcoder_with_name.svg" ], diff --git a/src/server/tc-communities/example-theme-default/metadata.json b/src/server/tc-communities/example-theme-default/metadata.json index 0b3d1ea977..c676e2cd31 100644 --- a/src/server/tc-communities/example-theme-default/metadata.json +++ b/src/server/tc-communities/example-theme-default/metadata.json @@ -1,4 +1,5 @@ { + "authorizedGroupIds": [], "communityId": "example-theme-default", "communityName": "Example Community", "logos": ["http://predix.topcoder.com/wp-content/uploads/sites/7/2016/11/topcoder-hat-logo.png"], diff --git a/src/server/tc-communities/example-theme-green/metadata.json b/src/server/tc-communities/example-theme-green/metadata.json index 91d1f086f6..81e74d3540 100644 --- a/src/server/tc-communities/example-theme-green/metadata.json +++ b/src/server/tc-communities/example-theme-green/metadata.json @@ -1,4 +1,5 @@ { + "authorizedGroupIds": [], "communityId": "example-theme-green", "communityName": "Example Community", "logos": ["http://predix.topcoder.com/wp-content/uploads/sites/7/2016/11/topcoder-hat-logo.png"], diff --git a/src/server/tc-communities/example-theme-red/metadata.json b/src/server/tc-communities/example-theme-red/metadata.json index ab6af6c337..ae2f0181c5 100644 --- a/src/server/tc-communities/example-theme-red/metadata.json +++ b/src/server/tc-communities/example-theme-red/metadata.json @@ -1,4 +1,5 @@ { + "authorizedGroupIds": [], "communityId": "example-theme-red", "communityName": "Example Community", "logos": ["http://predix.topcoder.com/wp-content/uploads/sites/7/2016/11/topcoder-hat-logo.png"], diff --git a/src/server/tc-communities/index.js b/src/server/tc-communities/index.js index 66b5624b4c..9ef59eb663 100644 --- a/src/server/tc-communities/index.js +++ b/src/server/tc-communities/index.js @@ -2,11 +2,43 @@ * Routes for demo API of tc-communities */ +import _ from 'lodash'; import express from 'express'; +import fs from 'fs'; import { getCommunitiesMetadata } from 'utils/tc'; const router = express.Router(); +/** + * GET challenge filters for public and specified private communities. + * As of now, it expects that array of IDs of groups a user has access to + * will be passed in the query. It uses these IDs to determine which communities + * should be included into the response. + */ +router.get('/', (req, res) => { + const filters = []; + const groups = new Set(req.query.groups || []); + const communities = fs.readdirSync(__dirname); + communities.forEach((community) => { + try { + const path = `${__dirname}/${community}/metadata.json`; + const data = JSON.parse(fs.readFileSync(path, 'utf8')); + if (!data.authorizedGroupIds + || data.authorizedGroupIds.some(id => groups.has(id))) { + filters.push({ + filter: data.challengeFilter || {}, + id: data.communityId, + name: data.communityName, + }); + } + } catch (e) { + _.noop(); + } + }); + filters.sort((a, b) => a.name.localeCompare(b.name)); + res.json(filters); +}); + /** * Endpoint for community meta data */ diff --git a/src/server/tc-communities/tc-prod-dev/metadata.json b/src/server/tc-communities/tc-prod-dev/metadata.json index dc10a85240..bcab2d7820 100644 --- a/src/server/tc-communities/tc-prod-dev/metadata.json +++ b/src/server/tc-communities/tc-prod-dev/metadata.json @@ -1,6 +1,7 @@ { - "challengeGroupId": "20000001", - "challengeFilterTag": "", + "challengeFilter": { + "groupIds": ["20000001"] + }, "communityId": "tc-prod-dev", "communityName": "Topcoder Product Development", "communitySelector": [{ @@ -15,6 +16,7 @@ "redirect": "https://ios.topcoder.com/", "value": "3" }], + "groupId": "20000001", "logos": [ "/themes/wipro/logo_topcoder_with_name.svg" ], diff --git a/src/server/tc-communities/wipro/metadata.json b/src/server/tc-communities/wipro/metadata.json index 9b85750043..e8fe31125a 100644 --- a/src/server/tc-communities/wipro/metadata.json +++ b/src/server/tc-communities/wipro/metadata.json @@ -2,8 +2,9 @@ "authorizedGroupIds": [ "20000000" ], - "challengeGroupId": "20000000", - "challengeFilterTag": "", + "challengeFilter": { + "groupIds": ["20000000"] + }, "communityId": "wipro", "communityName": "Wipro Hybrid Crowd", "communitySelector": [{ @@ -18,6 +19,7 @@ "redirect": "https://ios.topcoder.com/", "value": "3" }], + "groupId": "20000000", "leaderboardApiUrl": "https://api.topcoder.com/v4/looks/458/run/json/", "logos": [ "/themes/wipro/wipro-logo.png", diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index 222fe39bd6..c36bc73148 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -2,10 +2,14 @@ * Challenge listing actions. */ +/* global fetch */ + import _ from 'lodash'; +import qs from 'qs'; import { createActions } from 'redux-actions'; import { decodeToken } from 'tc-accounts'; import { getService } from 'services/challenges'; +import 'isomorphic-fetch'; /** * The maximum number of challenges to fetch in a single API call. Currently, @@ -63,6 +67,22 @@ function getChallengeTagsDone() { ); } +/** + * Gets from the backend challenge filters for public groups, and for + * the groups the authenticated user has access to. + * NOTE: At the moment it works with a mocked API. + * @param {Object} auth Optional + * @return {Promise} + */ +function getCommunityFilters(auth) { + let groups = []; + if (auth.profile && auth.profile.groups) { + groups = auth.profile.groups.map(g => g.id); + } + return fetch(`/api/tc-communities?${qs.stringify({ groups })}`) + .then(res => (res.ok ? res.json() : new Error(res.statusText))); +} + /** * Notifies about reloading of all active challenges. The UUID is stored in the * state, and only challenges fetched by getAllActiveChallengesDone action with @@ -198,12 +218,17 @@ export default createActions({ GET_CHALLENGE_TAGS_INIT: _.noop, GET_CHALLENGE_TAGS_DONE: getChallengeTagsDone, + GET_COMMUNITY_FILTERS: getCommunityFilters, + GET_DRAFT_CHALLENGES_INIT: getDraftChallengesInit, GET_DRAFT_CHALLENGES_DONE: getDraftChallengesDone, GET_PAST_CHALLENGES_INIT: getPastChallengesInit, GET_PAST_CHALLENGES_DONE: getPastChallengesDone, + /* Pass in community ID. */ + SELECT_COMMUNITY: _.identity, + SET_FILTER: _.identity, SET_SORT: (bucket, sort) => ({ bucket, sort }), diff --git a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx index ed39b729fe..cd68ae5cac 100644 --- a/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx +++ b/src/shared/components/challenge-listing/Filters/ChallengeFilters.jsx @@ -20,12 +20,15 @@ import './ChallengeFilters.scss'; export default function ChallengeFilters({ challengeGroupId, + communityFilters, communityName, expanded, filterState, isCardTypeSet, saveFilter, searchText, + selectCommunity, + selectedCommunityId, setCardType, setExpanded, setFilterState, @@ -128,11 +131,14 @@ export default function ChallengeFilters({
-
+
@@ -83,29 +77,16 @@ export default function FiltersPanel({ value={filterState.tags ? filterState.tags.join(',') : null} />
- {challengeGroupId ? ( -
- - +
@@ -163,18 +144,18 @@ export default function FiltersPanel({ } FiltersPanel.defaultProps = { - communityName: null, hidden: false, onSaveFilter: _.noop, onClose: _.noop, }; FiltersPanel.propTypes = { - challengeGroupId: PT.string.isRequired, - // communityName: PT.string, + communityFilters: PT.arrayOf(PT.shape()).isRequired, filterState: PT.shape().isRequired, hidden: PT.bool, onSaveFilter: PT.func, + selectCommunity: PT.func.isRequired, + selectedCommunityId: PT.string.isRequired, setFilterState: PT.func.isRequired, setSearchText: PT.func.isRequired, validKeywords: PT.arrayOf(PT.string).isRequired, diff --git a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx index ca7f96def7..6367eee4e1 100644 --- a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx +++ b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx @@ -88,7 +88,7 @@ export default function Bucket({ ) : null } { - !expandable && loadMore && !loading ? ( + expanded && !expandable && loadMore && !loading ? ( ) : null } diff --git a/src/shared/components/challenge-listing/Sidebar/BucketSelector/index.jsx b/src/shared/components/challenge-listing/Sidebar/BucketSelector/index.jsx index 71b0e10eaa..ef33836d81 100644 --- a/src/shared/components/challenge-listing/Sidebar/BucketSelector/index.jsx +++ b/src/shared/components/challenge-listing/Sidebar/BucketSelector/index.jsx @@ -21,6 +21,7 @@ export default function BucketSelector({ activeSavedFilter, buckets, challenges, + communityFilter, disabled, filterState, isAuth, @@ -29,7 +30,12 @@ export default function BucketSelector({ selectSavedFilter, setEditSavedFiltersMode, }) { - const filteredChallenges = challenges.filter(getFilterFunction(filterState)); + let filteredChallenges = challenges.filter(getFilterFunction(filterState)); + + if (communityFilter) { + filteredChallenges = filteredChallenges.filter( + getFilterFunction(communityFilter)); + } const getBucket = bucket => ( dispatch(sa.saveFilter(...rest)), selectBucket: bucket => dispatch(sa.selectBucket(bucket)), + selectCommunity: id => dispatch(cla.selectCommunity(id)), setFilterState: s => dispatch(cla.setFilter(s)), }; } @@ -105,12 +106,14 @@ function mapStateToProps(state, ownProps) { ...ownProps, ...state.challengeListing.filterPanel, activeBucket: cl.sidebar.activeBucket, + communityFilters: cl.communityFilters, filterState: cl.filter, getAvailableFilterName: () => getAvailableFilterName(state), loadingKeywords: cl.loadingChallengeTags, loadingSubtracks: cl.loadingChallengeSubtracks, validKeywords: cl.challengeTags, validSubtracks: cl.challengeSubtracks, + selectedCommunityId: cl.selectedCommunityId, tokenV2: state.auth.tokenV2, }; } diff --git a/src/shared/containers/challenge-listing/Listing/index.jsx b/src/shared/containers/challenge-listing/Listing/index.jsx index 8a5588531f..197954389f 100644 --- a/src/shared/containers/challenge-listing/Listing/index.jsx +++ b/src/shared/containers/challenge-listing/Listing/index.jsx @@ -37,6 +37,10 @@ class ListingContainer extends React.Component { componentDidMount() { this.props.markHeaderMenu(); + if (this.props.communityId) { + this.props.selectCommunity(this.props.communityId); + } + if (mounted) { logger.error('Attempt to mount multiple instances of ChallengeListingPageContainer at the same time!'); } else mounted = true; @@ -44,10 +48,10 @@ class ListingContainer extends React.Component { } componentDidUpdate(prevProps) { - const token = this.props.auth.tokenV3; - if (token) { - if (!prevProps.auth.tokenV3) setImmediate(() => this.loadChallenges()); - } else if (prevProps.auth.tokenV3) { + const profile = this.props.auth.profile; + if (profile) { + if (!prevProps.auth.profile) setImmediate(() => this.loadChallenges()); + } else if (prevProps.auth.profile) { setImmediate(() => { this.props.dropChallenges(); this.loadChallenges(); @@ -63,6 +67,7 @@ class ListingContainer extends React.Component { } loadChallenges() { + this.props.getCommunityFilters(this.props.auth); this.props.getAllActiveChallenges(this.props.auth.tokenV3); this.props.getDraftChallenges(0, this.props.auth.tokenV3); this.props.getPastChallenges(0, this.props.auth.tokenV3); @@ -128,6 +133,10 @@ class ListingContainer extends React.Component { getPastChallenges(1 + lastRequestedPageOfPastChallenges, tokenV3); } + let communityFilter = this.props.communityFilters.find(item => + item.id === this.props.selectedCommunityId); + if (communityFilter) communityFilter = communityFilter.filter; + return (
{/* For demo we hardcode banner properties so we can disable max-len linting */} @@ -151,12 +160,14 @@ class ListingContainer extends React.Component { challenges={challenges} challengeSubtracks={challengeSubtracks} challengeTags={challengeTags} + communityFilter={communityFilter} communityName={this.props.communityName} filterState={this.props.filter} loadingChallenges={Boolean(this.props.loadingActiveChallengesUUID)} loadingDraftChallenges={Boolean(this.props.loadingDraftChallengesUUID)} loadingPastChallenges={Boolean(this.props.loadingPastChallengesUUID)} selectBucket={selectBucket} + loadMoreDraft={loadMoreDraft} loadMorePast={loadMorePast} setFilterState={(state) => { @@ -190,6 +201,7 @@ class ListingContainer extends React.Component { ListingContainer.defaultProps = { challengeGroupId: '', + communityId: null, communityName: null, listingOnly: false, match: null, @@ -198,6 +210,7 @@ ListingContainer.defaultProps = { ListingContainer.propTypes = { auth: PT.shape({ + profile: PT.shape(), tokenV3: PT.string, user: PT.shape(), }).isRequired, @@ -206,10 +219,13 @@ ListingContainer.propTypes = { challenges: PT.arrayOf(PT.shape({})).isRequired, challengeSubtracks: PT.arrayOf(PT.string).isRequired, challengeTags: PT.arrayOf(PT.string).isRequired, + communityFilters: PT.arrayOf(PT.shape()).isRequired, dropChallenges: PT.func.isRequired, filter: PT.shape().isRequired, + communityId: PT.string, communityName: PT.string, getAllActiveChallenges: PT.func.isRequired, + getCommunityFilters: PT.func.isRequired, getDraftChallenges: PT.func.isRequired, getPastChallenges: PT.func.isRequired, lastRequestedPageOfDraftChallenges: PT.number.isRequired, @@ -219,8 +235,10 @@ ListingContainer.propTypes = { loadingPastChallengesUUID: PT.string.isRequired, markHeaderMenu: PT.func.isRequired, selectBucket: PT.func.isRequired, + selectCommunity: PT.func.isRequired, setFilter: PT.func.isRequired, activeBucket: PT.string.isRequired, + selectedCommunityId: PT.string.isRequired, sorts: PT.shape().isRequired, setSearchText: PT.func.isRequired, setSort: PT.func.isRequired, @@ -249,6 +267,7 @@ const mapStateToProps = (state) => { challenges: cl.challenges, challengeSubtracks: cl.challengeSubtracks, challengeTags: cl.challengeTags, + communityFilters: cl.communityFilters, lastRequestedPageOfDraftChallenges: cl.lastRequestedPageOfDraftChallenges, lastRequestedPageOfPastChallenges: cl.lastRequestedPageOfPastChallenges, loadingActiveChallengesUUID: cl.loadingActiveChallengesUUID, @@ -256,6 +275,7 @@ const mapStateToProps = (state) => { loadingPastChallengesUUID: cl.loadingPastChallengesUUID, loadingChallengeSubtracks: cl.loadingChallengeSubtracks, loadingChallengeTags: cl.loadingChallengeTags, + selectedCommunityId: cl.selectedCommunityId, sorts: cl.sorts, activeBucket: cl.sidebar.activeBucket, }; @@ -273,6 +293,7 @@ function mapDispatchToProps(dispatch) { dispatch(a.getAllActiveChallengesInit(uuid)); dispatch(a.getAllActiveChallengesDone(uuid, token)); }, + getCommunityFilters: auth => dispatch(a.getCommunityFilters(auth)), getDraftChallenges: (page, token) => { const uuid = shortid(); dispatch(a.getDraftChallengesInit(uuid, page)); @@ -284,6 +305,7 @@ function mapDispatchToProps(dispatch) { dispatch(a.getPastChallengesDone(uuid, page, token)); }, selectBucket: bucket => dispatch(sa.selectBucket(bucket)), + selectCommunity: id => dispatch(a.selectCommunity(id)), setFilter: state => dispatch(a.setFilter(state)), setSearchText: text => dispatch(fpa.setSearchText(text)), setSort: (bucket, sort) => dispatch(a.setSort(bucket, sort)), diff --git a/src/shared/containers/challenge-listing/Sidebar.jsx b/src/shared/containers/challenge-listing/Sidebar.jsx index d894e643f7..28285b1d01 100644 --- a/src/shared/containers/challenge-listing/Sidebar.jsx +++ b/src/shared/containers/challenge-listing/Sidebar.jsx @@ -23,10 +23,16 @@ class SidebarContainer extends React.Component { render() { const buckets = getBuckets(this.props.user && this.props.user.handle); const tokenV2 = this.props.tokenV2; + + let communityFilter = this.props.communityFilters.find(item => + item.id === this.props.selectedCommunityId); + if (communityFilter) communityFilter = communityFilter.filter; + return ( this.props.deleteSavedFilter(id, tokenV2)} selectSavedFilter={(index) => { const filter = this.props.savedFilters[index].filter; @@ -48,14 +54,17 @@ class SidebarContainer extends React.Component { } SidebarContainer.defaultProps = { + selectedCommunityId: null, tokenV2: null, user: null, }; SidebarContainer.propTypes = { + communityFilters: PT.arrayOf(PT.shape()).isRequired, deleteSavedFilter: PT.func.isRequired, getSavedFilters: PT.func.isRequired, savedFilters: PT.arrayOf(PT.shape()).isRequired, + selectedCommunityId: PT.string, selectSavedFilter: PT.func.isRequired, setFilter: PT.func.isRequired, setSearchText: PT.func.isRequired, @@ -85,6 +94,8 @@ function mapStateToProps(state) { disabled: (activeBucket === BUCKETS.ALL) && Boolean(pending.length), filterState: state.challengeListing.filter, isAuth: Boolean(state.auth.user), + communityFilters: state.challengeListing.communityFilters, + selectedCommunityId: state.challengeListing.selectedCommunityId, tokenV2: state.auth.tokenV2, user: state.auth.user, }; diff --git a/src/shared/containers/tc-communities/Page/index.jsx b/src/shared/containers/tc-communities/Page/index.jsx index a86f133f3a..5ca0870aac 100644 --- a/src/shared/containers/tc-communities/Page/index.jsx +++ b/src/shared/containers/tc-communities/Page/index.jsx @@ -142,6 +142,7 @@ class Page extends Component { case 'challenges': pageContent = ( ({ + ...state, selectedCommunityId: payload, + }), + [a.setFilter]: onSetFilter, [a.setSort]: (state, { payload }) => ({ ...state, @@ -220,6 +236,12 @@ function create(initialState) { challenges: [], challengeSubtracks: [], challengeTags: [], + + communityFilters: [{ + id: '', + name: 'All', + }], + filter: {}, lastRequestedPageOfDraftChallenges: -1, @@ -233,6 +255,8 @@ function create(initialState) { loadingChallengeSubtracks: false, loadingChallengeTags: false, + selectedCommunityId: '', + sorts: {}, })); } diff --git a/src/shared/reducers/tc-communities/meta.js b/src/shared/reducers/tc-communities/meta.js index 482d3d5437..e89890fd29 100644 --- a/src/shared/reducers/tc-communities/meta.js +++ b/src/shared/reducers/tc-communities/meta.js @@ -18,7 +18,7 @@ function onDone(state, action) { ...state, authorizedGroupIds: action.payload.authorizedGroupIds, challengeFilterTag: action.payload.challengeFilterTag, - challengeGroupId: action.payload.challengeGroupId, + challengeGroupId: action.payload.groupId, communityId: action.payload.communityId, communityName: action.payload.communityName, communitySelector: action.payload.communitySelector, diff --git a/src/shared/utils/challenge-listing/filter.js b/src/shared/utils/challenge-listing/filter.js index d7ef826960..e55a7c03a0 100644 --- a/src/shared/utils/challenge-listing/filter.js +++ b/src/shared/utils/challenge-listing/filter.js @@ -15,7 +15,7 @@ * endDate {Number|String} - Permits only those challenges with submission * deadline before this date. * - * groupIds {Object} - Permits only the challenges belonging to at least one + * groupIds {Array} - Permits only the challenges belonging to at least one * of the groups which IDs are presented as keys in this object. * * registrationOpen {Boolean} - Permits only the challenges with open or closed @@ -61,6 +61,11 @@ function filterByEndDate(challenge, state) { return moment(state.endDate).isAfter(challenge.createdAt); } +function filterByGroupIds(challenge, state) { + if (!state.groupIds) return true; + return state.groupIds.some(id => challenge.groups[id]); +} + function filterByRegistrationOpen(challenge, state) { if (_.isUndefined(state.registrationOpen)) return true; const isRegOpen = () => { @@ -168,6 +173,7 @@ export function getFilterFunction(state) { return challenge => filterByStatus(challenge, state) && filterByTrack(challenge, state) && filterByUpcoming(challenge, state) + && filterByGroupIds(challenge, state) && filterByText(challenge, state) && filterByTags(challenge, state) && filterBySubtracks(challenge, state) From 3f7f7b6a898bd9f7158f53cb0e0f49740134db74 Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Thu, 29 Jun 2017 15:12:18 +0200 Subject: [PATCH 20/20] Rapid corrections of Dashboard to make it work with updated ChallengeListing --- src/shared/actions/challenge-listing/index.js | 13 +- src/shared/containers/Dashboard/index.jsx | 127 +++--------------- src/shared/utils/tc.js | 2 + 3 files changed, 27 insertions(+), 115 deletions(-) diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index c36bc73148..50c9237a3b 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -127,14 +127,17 @@ function getAllActiveChallengesDone(uuid, tokenV3) { * are not attributed to the user there. This block of code marks user * challenges in an efficient way. */ if (uch) { - const set = new Set(); - uch.forEach(item => set.add(item.id)); - umm.forEach(item => set.add(item.id)); + const map = {}; + uch.forEach((item) => { map[item.id] = item; }); + umm.forEach((item) => { map[item.id] = item; }); challenges.forEach((item) => { - if (set.has(item.id)) { + if (map[item.id]) { /* It is fine to reassing, as the array we modifying is created just * above within the same function. */ - item.users[user] = true; // eslint-disable-line no-param-reassign + /* eslint-disable no-param-reassign */ + item.users[user] = true; + item.userDetails = map[item.id].userDetails; + /* eslint-enable no-param-reassign */ } }); } diff --git a/src/shared/containers/Dashboard/index.jsx b/src/shared/containers/Dashboard/index.jsx index a1736314a8..bcfcdaa419 100644 --- a/src/shared/containers/Dashboard/index.jsx +++ b/src/shared/containers/Dashboard/index.jsx @@ -25,93 +25,29 @@ import './styles.scss'; // The container component class DashboardPageContainer extends React.Component { - constructor(props) { - super(props); - this.state = { - loadingMyChallenges: false, - loadingMyMarathon: false, - loadingIosChallenges: false, - }; - } - componentDidMount() { if (!this.props.auth.tokenV2) { + /* TODO: dev/prod URLs should be generated based on the config, + * now it is hardcoded with dev URL - wrong! */ location.href = 'http://accounts.topcoder-dev.com/#!/member?retUrl=http:%2F%2Flocal.topcoder-dev.com:3000%2Fmy-dashboard'; return false; } + this.props.getAllActiveChallenges(this.props.auth.tokenV3); this.props.getBlogs(); return true; } componentDidUpdate(prevProps) { const { user, tokenV3 } = this.props.auth; - const { challenges } = this.props.challengeListing; - const { iosRegistered } = this.props.dashboard; if (tokenV3 && tokenV3 !== prevProps.auth.tokenV3) { setImmediate(() => { + this.props.getAllActiveChallenges(tokenV3); this.props.getSubtrackRanks(tokenV3, user.handle); this.props.getSRMs(tokenV3, user.handle); this.props.getIosRegistration(tokenV3, user.userId); this.props.getUserFinancials(tokenV3, user.handle); }); } - if (user && !prevProps.auth.user && (!challenges || !challenges.length)) { - setImmediate( - () => { - this.setState({ loadingMyChallenges: true }); - this.props.getChallenges( - { - status: 'ACTIVE', - }, { - limit: 8, - orderBy: 'submissionEndDate', - }, tokenV3, 'active', user.handle, - ).then(() => { - this.setState({ loadingMyChallenges: false }); - }) - .catch(() => { - this.setState({ loadingMyChallenges: false }); - }); - - this.setState({ loadingMyMarathon: true }); - this.props.getMarathonMatches( - { - status: 'ACTIVE', - }, { - limit: 8, - }, tokenV3, 'myActiveMM', user.handle, - ) - .then(() => { - this.setState({ loadingMyMarathon: false }); - }) - .catch(() => { - this.setState({ loadingMyMarathon: false }); - }); - }, - ); - } - if (iosRegistered && !prevProps.dashboard.iosRegistered) { - setImmediate( - () => { - this.setState({ loadingIosChallenges: true }); - this.props.getChallenges({ - platforms: 'ios', - technologies: 'swift', - status: 'active', - }, { - limit: 3, - offset: 0, - orderBy: 'submissionEndDate asc', - }) - .then(() => { - this.setState({ loadingIosChallenges: false }); - }) - .catch(() => { - this.setState({ loadingIosChallenges: false }); - }); - }, - ); - } } render() { @@ -131,7 +67,9 @@ class DashboardPageContainer extends React.Component { _.filter(challenges, c => c.platforms === 'iOS'), ); - const { loadingMyChallenges, loadingMyMarathon, loadingIosChallenges } = this.state; + const loadingActiveChallenges = + Boolean(this.props.challengeListing.loadingActiveChallengesUUID); + return (
@@ -149,11 +87,11 @@ class DashboardPageContainer extends React.Component {
{ - (loadingMyMarathon || loadingMyChallenges) && + loadingActiveChallenges && } { - !loadingMyChallenges && !loadingMyMarathon && + !loadingActiveChallenges &&
{ - loadingIosChallenges && + loadingActiveChallenges && } { - !loadingIosChallenges && + !loadingActiveChallenges && ({ auth: state.auth, dashboard: state.dashboard, @@ -293,8 +197,11 @@ const mapDispatchToProps = dispatch => ({ dispatch(actions.dashboard.getSubtrackRanksInit()); dispatch(actions.dashboard.getSubtrackRanksDone(tokenV3, handle)); }, - getChallenges: (...rest) => getChallenges(dispatch, ...rest), - getMarathonMatches: (...rest) => getMarathonMatches(dispatch, ...rest), + getAllActiveChallenges: (tokenV3) => { + const uuid = shortid(); + dispatch(cActions.challengeListing.getAllActiveChallengesInit(uuid)); + dispatch(cActions.challengeListing.getAllActiveChallengesDone(uuid, tokenV3)); + }, getSRMs: (tokenV3, handle) => { dispatch(actions.dashboard.getSrmsInit()); dispatch(actions.dashboard.getSrmsDone(tokenV3, handle, { diff --git a/src/shared/utils/tc.js b/src/shared/utils/tc.js index d5bb978a2f..86984aeb70 100644 --- a/src/shared/utils/tc.js +++ b/src/shared/utils/tc.js @@ -143,6 +143,8 @@ export function stripUnderscore(string) { * @param {array} challenges challenges array to process * @return {array} processed challenges array */ +/* TODO: This function should be mixed into normalization function + * of the challenges service. */ export function processActiveDevDesignChallenges(challenges) { return _.map(challenges, (c) => { const challenge = _.cloneDeep(c);