diff --git a/.circleci/config.yml b/.circleci/config.yml index c94aa0f975..15009f5042 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -276,6 +276,7 @@ workflows: branches: only: - develop + - feature/recommender-sync-develop # This is alternate dev env for parallel testing - "build-test": context : org-global @@ -289,7 +290,7 @@ workflows: filters: branches: only: - - gigs-housekeep + - free # This is beta env for production soft releases - "build-prod-beta": context : org-global @@ -304,6 +305,7 @@ workflows: branches: only: - develop + - feature/recommender-sync-develop - "approve-smoke-test-on-staging": type: approval requires: diff --git a/__tests__/shared/components/challenge-listing/Filters/__snapshots__/FiltersPanel.jsx.snap b/__tests__/shared/components/challenge-listing/Filters/__snapshots__/FiltersPanel.jsx.snap index 5297459e04..884185c3f9 100644 --- a/__tests__/shared/components/challenge-listing/Filters/__snapshots__/FiltersPanel.jsx.snap +++ b/__tests__/shared/components/challenge-listing/Filters/__snapshots__/FiltersPanel.jsx.snap @@ -137,7 +137,7 @@ exports[`Matches shallow shapshot 2`] = ` - Challenge Type + Type
+ + Group 23 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/icon-verified.svg b/src/assets/images/icon-verified.svg new file mode 100644 index 0000000000..a38fcd7263 --- /dev/null +++ b/src/assets/images/icon-verified.svg @@ -0,0 +1,37 @@ + + + Group 6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index 346e1adc1b..bce1df1e32 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -228,9 +228,24 @@ function getActiveChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter // })); } +/** + * Gets open for registration challenges + * @param {String} uuid + * @param {Number} page + * @param {Object} backendFilter Backend filter to use. + * @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. + * @param {Object} frontFilter + * @param {boolean} recommended recommended toggle is on or off + * @param {String} handle user handle + + * @return {Promise} + */ function getOpenForRegistrationChallengesDone(uuid, page, backendFilter, - tokenV3, frontFilter = {}) { + tokenV3, frontFilter = {}, recommended = false, handle) { const { sorts } = frontFilter; + const sortOrder = SORT[sorts[BUCKETS.OPEN_FOR_REGISTRATION]]; const filter = { backendFilter, frontFilter: { @@ -240,11 +255,20 @@ function getOpenForRegistrationChallengesDone(uuid, page, backendFilter, perPage: PAGE_SIZE, page: page + 1, sortBy: sorts[BUCKETS.OPEN_FOR_REGISTRATION], - sortOrder: SORT[sorts[BUCKETS.OPEN_FOR_REGISTRATION]].order, + sortOrder: sortOrder ? sortOrder.order : 'asc', }, }; delete filter.frontFilter.sorts; const service = getService(tokenV3); + if (recommended) { + return service.getRecommendedChallenges(filter, handle).then(ch => ({ + uuid, + openForRegistrationChallenges: ch.challenges, + meta: ch.meta, + frontFilter, + })); + } + return service.getChallenges(filter).then(ch => ({ uuid, openForRegistrationChallenges: ch.challenges, @@ -522,6 +546,7 @@ export default createActions({ DROP_MY_CHALLENGES: _.noop, DROP_ALL_CHALLENGES: _.noop, DROP_PAST_CHALLENGES: _.noop, + DROP_RECOMMENDED_CHALLENGES: _.noop, // GET_ALL_ACTIVE_CHALLENGES_INIT: getAllActiveChallengesInit, // GET_ALL_ACTIVE_CHALLENGES_DONE: getAllActiveChallengesDone, diff --git a/src/shared/components/challenge-detail/Header/ChallengeTags.jsx b/src/shared/components/challenge-detail/Header/ChallengeTags.jsx index ce6190cc3a..e0be111124 100644 --- a/src/shared/components/challenge-detail/Header/ChallengeTags.jsx +++ b/src/shared/components/challenge-detail/Header/ChallengeTags.jsx @@ -6,8 +6,10 @@ */ +import _ from 'lodash'; import React from 'react'; import PT from 'prop-types'; +import { config } from 'topcoder-react-utils'; import { Tag, @@ -22,15 +24,21 @@ import { } from 'topcoder-react-ui-kit'; import { COMPETITION_TRACKS } from 'utils/tc'; +import VerifiedTag from 'components/challenge-listing/VerifiedTag'; +import MatchScore from 'components/challenge-listing/ChallengeCard/MatchScore'; +import { calculateScore } from '../../../utils/challenge-listing/helper'; +import './style.scss'; export default function ChallengeTags(props) { const { + challengeId, challengesUrl, track, challengeType, events, technPlatforms, setChallengeListingFilter, + openForRegistrationChallenges, } = props; let EventTag; @@ -56,6 +64,13 @@ export default function ChallengeTags(props) { throw new Error('Wrong competition track value'); } + + const filteredChallenge = _.find(openForRegistrationChallenges, { id: challengeId }); + const matchSkills = filteredChallenge ? filteredChallenge.match_skills || [] : []; + const matchScore = filteredChallenge ? filteredChallenge.jaccard_index || [] : 0; + + const tags = technPlatforms.filter(tag => !matchSkills.includes(tag)); + return (
{ @@ -83,7 +98,22 @@ export default function ChallengeTags(props) { )) } { - technPlatforms.map(tag => ( + matchScore > 0 && config.ENABLE_RECOMMENDER && ( + + + + ) + } + { + matchSkills.map(item => ( + + )) + } + { + tags.map(tag => ( tag && (
{(hasRecommendedChallenges || hasThriveArticles) && (
@@ -505,6 +508,7 @@ ChallengeHeader.propTypes = { phases: PT.any, roundId: PT.any, prizeSets: PT.any, + match_skills: PT.arrayOf(PT.string), }).isRequired, challengesUrl: PT.string.isRequired, hasRegistered: PT.bool.isRequired, @@ -525,4 +529,5 @@ ChallengeHeader.propTypes = { hasFirstPlacement: PT.bool.isRequired, isMenuOpened: PT.bool, mySubmissions: PT.arrayOf(PT.shape()).isRequired, + openForRegistrationChallenges: PT.shape().isRequired, }; diff --git a/src/shared/components/challenge-detail/Header/style.scss b/src/shared/components/challenge-detail/Header/style.scss index f78bd1c624..2e4d9c7064 100644 --- a/src/shared/components/challenge-detail/Header/style.scss +++ b/src/shared/components/challenge-detail/Header/style.scss @@ -356,3 +356,9 @@ position: relative; top: -5px; } + +.matchScoreWrap { + width: 100%; + margin-right: -2px; + padding: 0; +} diff --git a/src/shared/components/challenge-listing/ChallengeCard/MatchScore/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/MatchScore/index.jsx new file mode 100644 index 0000000000..9502ef8eb8 --- /dev/null +++ b/src/shared/components/challenge-listing/ChallengeCard/MatchScore/index.jsx @@ -0,0 +1,22 @@ +import PT from 'prop-types'; +import React from 'react'; +import { DevelopmentTrackEventTag } from 'topcoder-react-ui-kit'; +import './style.scss'; + +export default function MatchScore({ score }) { + return ( +
+ + {score}% match + +
+ ); +} + +MatchScore.defaultProps = { + score: 0, +}; + +MatchScore.propTypes = { + score: PT.number, +}; diff --git a/src/shared/components/challenge-listing/ChallengeCard/MatchScore/style.scss b/src/shared/components/challenge-listing/ChallengeCard/MatchScore/style.scss new file mode 100644 index 0000000000..80d12a53cb --- /dev/null +++ b/src/shared/components/challenge-listing/ChallengeCard/MatchScore/style.scss @@ -0,0 +1,7 @@ +@import "~styles/mixins"; + +.matchScoreTag { + margin-right: 0; + margin-bottom: 4px; + display: inline-block; +} diff --git a/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx index d3315863aa..0d91283aa5 100644 --- a/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx +++ b/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx @@ -312,6 +312,6 @@ ChallengeStatus.propTypes = { openChallengesInNewTabs: PT.bool, // eslint-disable-line react/no-unused-prop-types selectChallengeDetailsTab: PT.func.isRequired, className: PT.string, - userId: PT.string, + userId: PT.number, isLoggedIn: PT.bool.isRequired, }; diff --git a/src/shared/components/challenge-listing/ChallengeCard/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/index.jsx index ab1dec84e4..8ffc8fb7de 100644 --- a/src/shared/components/challenge-listing/ChallengeCard/index.jsx +++ b/src/shared/components/challenge-listing/ChallengeCard/index.jsx @@ -13,6 +13,8 @@ import Tags from '../Tags'; import ChallengeStatus from './Status'; import TrackAbbreviationTooltip from '../Tooltips/TrackAbbreviationTooltip'; +import MatchScore from './MatchScore'; +import { calculateScore } from '../../../utils/challenge-listing/helper'; import './style.scss'; /* TODO: Note that this component uses a dirty trick to cheat linter and to be @@ -47,6 +49,7 @@ function ChallengeCard({ const registrationPhase = (challenge.phases || []).filter(phase => phase.name === 'Registration')[0]; const isRegistrationOpen = registrationPhase ? registrationPhase.isOpen : false; + const isRecommendedChallenge = challenge.jaccard_index; return (
@@ -67,19 +70,40 @@ function ChallengeCard({
- selectChallengeDetailsTab(DETAIL_TABS.DETAILS)} - to={challengeDetailLink} - styleName="challenge-title" - openNewTab={openChallengesInNewTabs} - >

{challenge.name}

- +
+ selectChallengeDetailsTab(DETAIL_TABS.DETAILS)} + to={challengeDetailLink} + styleName="challenge-title" + openNewTab={openChallengesInNewTabs} + >

{challenge.name}

+ +
{challenge.status === 'Active' ? 'Ends ' : 'Ended '} {getEndDate(challenge)} - { challenge.tags.length > 0 + { + isRecommendedChallenge + && + } + { + isRecommendedChallenge + && challenge.match_skills.length > 0 + && ( + expandTag(challenge.id)} + verifiedTags={challenge.match_skills} + recommended + /> + ) + } + { !isRecommendedChallenge + && challenge.tags.length > 0 && ( { + if (!isFilterEmpty(filterState, past ? 'past' : '', activeBucket) + && recommendedToggle + && filterState.types.length !== _.uniq(filterState.types).length + ) { + setFilterState({ + ...filterState, + types: _.uniq(filterState.types), + }); + } + + if (filterState.recommended) { + setRecommendedToggle(true); + } + }, [filterState]); + + const onSwitchRecommendedChallenge = (on) => { + setFilterState({ ..._.clone(filterState), recommended: on }); + selectBucket(BUCKETS.OPEN_FOR_REGISTRATION); + + if (on) { + setSort('openForRegistration', 'bestMatch'); + setFilterState({ + ...filterState, + tracks: { + Dev: true, + Des: true, + DS: true, + QA: true, + }, + search: '', + tags: [], + types: ['CH', 'F2F', 'TSK'], + groups: [], + events: [], + endDateStart: null, + startDateEnd: null, + recommended: true, + }); + } else { + setSort('openForRegistration', 'startDate'); + setFilterState({ + ...filterState, + recommended: false, + }); + } + setRecommendedToggle(on); + }; + + const recommendedCheckboxTip = ( +
+

Shows available challenges
that match your skills

+
+ ); + return (
@@ -412,7 +473,7 @@ export default function FiltersPanel({
- Challenge Type + Type
{ @@ -494,7 +555,7 @@ export default function FiltersPanel({ ) : null } - { !isReviewOpportunitiesBucket + { !isReviewOpportunitiesBucket && !(recommendedToggle && activeBucket === 'openForRegistration') && (
@@ -538,13 +599,51 @@ export default function FiltersPanel({
) } + + { + isRecommendedChallengesVisible && _.get(auth, 'user.userId') + && ( +
+ + + + +
+ + circle-icon + +
+
+ ) + }
+ { + isRecommendedChallengesVisible && _.get(auth, 'user.userId') + && (
) + } +