diff --git a/.exchange-rates.cache b/.exchange-rates.cache index 3023d7ea24..4c14f2deb0 100644 --- a/.exchange-rates.cache +++ b/.exchange-rates.cache @@ -1 +1 @@ -{"disclaimer":"Usage subject to terms: https://openexchangerates.org/terms","license":"https://openexchangerates.org/license","timestamp":1505811600,"base":"USD","rates":{"AED":3.673014,"AFN":68.614,"ALL":111.776085,"AMD":478.295,"ANG":1.77968,"AOA":165.9215,"ARS":17.0995,"AUD":1.252216,"AWG":1.796504,"AZN":1.6855,"BAM":1.635351,"BBD":2,"BDT":80.708351,"BGN":1.63299,"BHD":0.377284,"BIF":1745.45,"BMD":1,"BND":1.348334,"BOB":6.963698,"BRL":3.13655,"BSD":1,"BTC":0.000253137519,"BTN":64.166558,"BWP":10.15073,"BYN":1.938191,"BZD":2.015375,"CAD":1.228379,"CDF":1562.881563,"CHF":0.961344,"CLF":0.02325,"CLP":624.6,"CNH":6.585748,"CNY":6.583106,"COP":2909,"CRC":576.745,"CUC":1,"CUP":25.5,"CVE":92.55,"CZK":21.791915,"DJF":178.57,"DKK":6.209958,"DOP":47.241072,"DZD":111.282333,"EGP":17.6487,"ERN":15.331922,"ETB":23.566647,"EUR":0.834449,"FJD":2.012549,"FKP":0.742007,"GBP":0.742007,"GEL":2.4717,"GGP":0.742007,"GHS":4.381672,"GIP":0.742007,"GMD":45.95,"GNF":8960.5,"GTQ":7.292123,"GYD":207.92,"HKD":7.803084,"HNL":23.385811,"HRK":6.235527,"HTG":63.0095,"HUF":258.117002,"IDR":13264.557907,"ILS":3.52281,"IMP":0.742007,"INR":64.245,"IQD":1165.609957,"IRR":33337.5,"ISK":105.94,"JEP":0.742007,"JMD":130.865,"JOD":0.709001,"JPY":111.5666875,"KES":103.367854,"KGS":68.455728,"KHR":4058.3,"KMF":412.561444,"KPW":900,"KRW":1131.54,"KWD":0.301307,"KYD":0.833119,"KZT":340.305898,"LAK":8284.05,"LBP":1507.65,"LKR":153.101598,"LRD":117.111232,"LSL":13.182434,"LYD":1.361748,"MAD":9.333026,"MDL":17.669492,"MGA":2989.5,"MKD":51.401508,"MMK":1345.8,"MNT":2468.97015,"MOP":8.049209,"MRO":364.935,"MUR":33.4205,"MVR":15.450233,"MWK":724.527458,"MXN":17.810955,"MYR":4.191489,"MZN":61.732957,"NAD":13.182434,"NGN":360.37226,"NIO":30.359705,"NOK":7.804146,"NPR":102.548085,"NZD":1.372687,"OMR":0.385015,"PAB":1,"PEN":3.247336,"PGK":3.23879,"PHP":50.99,"PKR":105.333422,"PLN":3.5831,"PYG":5658.3,"QAR":3.656908,"RON":3.840574,"RSD":99.257652,"RUB":58.1634,"RWF":831.55,"SAR":3.75035,"SBD":7.823231,"SCR":13.55,"SDG":6.675602,"SEK":7.95019,"SGD":1.348793,"SHP":0.742007,"SLL":7558.984779,"SOS":578.446731,"SRD":7.438,"SSP":126.2714,"STD":20487.565958,"SVC":8.747714,"SYP":515.00499,"SZL":13.187884,"THB":33.083,"TJS":8.795919,"TMT":3.50998,"TND":2.437494,"TOP":2.2232,"TRY":3.501853,"TTD":6.74491,"TWD":30.156,"TZS":2243.2,"UAH":26.155965,"UGX":3595.3,"USD":1,"UYU":28.986914,"UZS":8090.5,"VEF":9.995002,"VND":22731.335859,"VUV":104.480024,"WST":2.480432,"XAF":547.362622,"XAG":0.05821916,"XAU":0.00076407,"XCD":2.70255,"XDR":0.702671,"XOF":547.362622,"XPD":0.00107407,"XPF":99.576246,"XPT":0.00104276,"YER":250.344142,"ZAR":13.330636,"ZMW":9.633257,"ZWL":322.355011}} \ No newline at end of file +{"disclaimer":"Usage subject to terms: https://openexchangerates.org/terms","license":"https://openexchangerates.org/license","timestamp":1506333600,"base":"USD","rates":{"AED":3.673158,"AFN":68.6465,"ALL":112.58,"AMD":479.63649,"ANG":1.785999,"AOA":165.9215,"ARS":17.3005,"AUD":1.25734,"AWG":1.794996,"AZN":1.7,"BAM":1.639912,"BBD":2,"BDT":82.238586,"BGN":1.644233,"BHD":0.377199,"BIF":1754.926108,"BMD":1,"BND":1.349018,"BOB":6.998006,"BRL":3.125,"BSD":1,"BTC":0.000265269665,"BTN":64.934734,"BWP":10.197008,"BYN":1.942719,"BZD":2.022299,"CAD":1.232918,"CDF":1562.881563,"CHF":0.973363,"CLF":0.02322,"CLP":627.82,"CNH":6.614113,"CNY":6.621016,"COP":2905.8,"CRC":576.881761,"CUC":1,"CUP":25.5,"CVE":92.675,"CZK":21.899438,"DJF":178.57,"DKK":6.259235,"DOP":47.311859,"DZD":112.421483,"EGP":17.668,"ERN":15.340012,"ETB":23.42892,"EUR":0.841295,"FJD":2.011699,"FKP":0.739307,"GBP":0.739307,"GEL":2.4777,"GGP":0.739307,"GHS":4.4175,"GIP":0.739307,"GMD":45.97,"GNF":8979.55,"GTQ":7.319581,"GYD":206.814115,"HKD":7.812934,"HNL":23.470563,"HRK":6.2963,"HTG":63.622543,"HUF":261.175784,"IDR":13329.280094,"ILS":3.51128,"IMP":0.739307,"INR":65.09,"IQD":1170.400369,"IRR":33402.5,"ISK":107.805414,"JEP":0.739307,"JMD":130.83531,"JOD":0.709101,"JPY":112.0615,"KES":103.225,"KGS":68.451752,"KHR":4077.641667,"KMF":412.6,"KPW":900,"KRW":1131.7025,"KWD":0.301675,"KYD":0.836119,"KZT":342.36841,"LAK":8310.85,"LBP":1508.862443,"LKR":153.01,"LRD":117.254872,"LSL":13.248849,"LYD":1.360769,"MAD":9.37926,"MDL":17.639888,"MGA":3056.925758,"MKD":51.812066,"MMK":1368.61006,"MNT":2460.413333,"MOP":8.070913,"MRO":365.964861,"MUR":33.475,"MVR":15.409873,"MWK":725.52,"MXN":17.795665,"MYR":4.198771,"MZN":62.012392,"NAD":13.248849,"NGN":358.522572,"NIO":30.342742,"NOK":7.823037,"NPR":103.982045,"NZD":1.37505,"OMR":0.38502,"PAB":1,"PEN":3.246393,"PGK":3.205299,"PHP":50.6885,"PKR":105.715129,"PLN":3.592565,"PYG":5707.227124,"QAR":3.65395,"RON":3.868876,"RSD":100.579362,"RUB":57.3153,"RWF":834.26744,"SAR":3.7502,"SBD":7.746542,"SCR":13.150523,"SDG":6.699279,"SEK":8.025323,"SGD":1.350376,"SHP":0.739307,"SLL":7550,"SOS":580.12834,"SRD":7.438,"SSP":126.6773,"STD":20559.200196,"SVC":8.778896,"SYP":514.98999,"SZL":13.24451,"THB":33.12,"TJS":8.839108,"TMT":3.499986,"TND":2.452101,"TOP":2.21298,"TRY":3.518075,"TTD":6.798325,"TWD":30.224452,"TZS":2244.2,"UAH":26.338725,"UGX":3637.566138,"USD":1,"UYU":28.969536,"UZS":8109.75,"VEF":9.985022,"VND":22745.133333,"VUV":104.150397,"WST":2.491006,"XAF":551.853344,"XAG":0.05893621,"XAU":0.00077184,"XCD":2.70255,"XDR":0.702884,"XOF":551.853344,"XPD":0.0010835,"XPF":100.393198,"XPT":0.00106785,"YER":250.25,"ZAR":13.26789,"ZMW":9.536229,"ZWL":322.355011}} \ No newline at end of file diff --git a/__tests__/shared/actions/tc-communities/index.js b/__tests__/shared/actions/tc-communities/index.js index 9f4b89777b..27ebc9a838 100644 --- a/__tests__/shared/actions/tc-communities/index.js +++ b/__tests__/shared/actions/tc-communities/index.js @@ -3,7 +3,7 @@ import actions from 'actions/tc-communities/index'; describe('tcCommunity.joinDone at frontend with 404 response', () => { global.fetch = jest.fn(() => Promise.resolve({ ok: true, - json: () => ({ + json: () => Promise.resolve({ result: { status: 200, content: 'dummy', diff --git a/__tests__/shared/components/SubmissionManagement/__snapshots__/Submission.jsx.snap b/__tests__/shared/components/SubmissionManagement/__snapshots__/Submission.jsx.snap index 4d5b552545..1f691cbda4 100644 --- a/__tests__/shared/components/SubmissionManagement/__snapshots__/Submission.jsx.snap +++ b/__tests__/shared/components/SubmissionManagement/__snapshots__/Submission.jsx.snap @@ -10,7 +10,7 @@ exports[`Snapshot match 1`] = ` preview diff --git a/__tests__/shared/containers/tc-communities/JoinCommunity.jsx b/__tests__/shared/containers/tc-communities/JoinCommunity.jsx index db345a03ed..73c753b003 100644 --- a/__tests__/shared/containers/tc-communities/JoinCommunity.jsx +++ b/__tests__/shared/containers/tc-communities/JoinCommunity.jsx @@ -39,6 +39,9 @@ describe('full render connnected component and dispatch actions', () => { }, tokenV3: 'tokenV3', }, + groups: { + groups: {}, + }, }; const mockStore = configureStore(); diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 245a4e92a7..19dea3a43a 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -9,6 +9,7 @@ - [SCSS](#basics-scss) - [Unit Tests](#basics-unit-tests) - [Code Quality](#basics-code-quality) + - [Documentation](#basics-documentation) - [File Names](#basics-file-names) 2. [React](#react) 3. [Redux](#redux) @@ -29,6 +30,8 @@ **Code Quality:** In general, you should write a neat and efficient code, covered by adequate amount of comments. Each JS module should have a brief header comment, outlining the content and purpose of that module. Comment all functions / methods, descibing what they do, how they work, what are the types and purposes of their arguments. You may omit such comments for standard React / Redux methods, small functions, etc. You should comment any non-trivial code; or trivial code that appears in strange places. If you note anything that can / should be improved in future, feel free to leave `TODO:` comments, and / or open issue tickets in the repo. +**Documentation:** All textual documentation must be in Markdown format, and it should be located inside [`/docs`](https://github.com/topcoder-platform/community-app/tree/develop/docs) or any of its sub-folders. To document REST APIs provided by our server we maintain a Postman collection in the same folder *Does not exist in the develop or master branches yet, but will appear in both soon*. + **File Names:** If a JSX file, or a folder containing `index.jsx`, exports a React component as the default export call it using CamelCase, i.g. `MyReactComponent.jsx`, or just `MyReactComponent` for folder. All other files an folders should be named as `yet-another-module.js`. ### React diff --git a/docs/how-to-add-a-new-topcoder-community.md b/docs/how-to-add-a-new-topcoder-community.md index ee708700fc..5d764f33b2 100644 --- a/docs/how-to-add-a-new-topcoder-community.md +++ b/docs/how-to-add-a-new-topcoder-community.md @@ -88,7 +88,7 @@ To add a new community with the name **demo**, we should follow the following pr - `points` - Points are shown rather than the prizes. The points are taken from `drPoints` field of challenge objects. There is no prizes tooltip in this case. - `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. + - `groupId` - *String* - Main user group of the community. All members of this group, and its descendant groups are treated as members of this community. Also, any challenges belonging to these groups are considered to be belonging to the community. - `leaderboardApiUrl` - *String* - Endpoint from where the leaderboard data should be loaded. - `logos` - *String Array | Object Array* - Array of image URLs to insert as logos into the left corner of community's header, alternatively the array may contain JS objects of shape ``` diff --git a/src/server/tc-communities/index.js b/src/server/tc-communities/index.js index 467d6cda25..980f82796f 100644 --- a/src/server/tc-communities/index.js +++ b/src/server/tc-communities/index.js @@ -5,7 +5,13 @@ import _ from 'lodash'; import express from 'express'; import fs from 'fs'; -import { getCommunitiesMetadata } from 'utils/tc'; +import { getService as getGroupsService } from 'services/groups'; +import { + addGroup, + getAuthTokens, + getCommunitiesMetadata, + isGroupMember, +} from 'utils/tc'; const router = express.Router(); @@ -16,30 +22,47 @@ const router = express.Router(); * should be included into the response. */ router.get('/', (req, res) => { + let apiGroups = {}; + const tokens = getAuthTokens(req); + const groupsService = getGroupsService(tokens.tokenV3); const list = []; - const groups = new Set(req.query.groups || []); const communities = fs.readdirSync(__dirname); - communities.forEach((community) => { + const userGroups = req.query.groups + ? req.query.groups.map(id => ({ id })) : []; + Promise.all(communities.map((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))) { - list.push({ - challengeFilter: data.challengeFilter || {}, - communityId: data.communityId, - communityName: data.communityName, - description: data.description, - groupId: data.groupId, - image: data.image, - }); - } + const promise = data.authorizedGroupIds ? ( + Promise.all(data.authorizedGroupIds.map((id) => { + if (!apiGroups[id]) { + return groupsService.get(id).then((group) => { + apiGroups = addGroup(apiGroups, group); + }).catch(_.noop); + } + return undefined; + })) + ) : Promise.resolve(); + return promise.then(() => { + if (!data.authorizedGroupIds + || isGroupMember(data.authorizedGroupIds, userGroups, apiGroups)) { + list.push({ + challengeFilter: data.challengeFilter || {}, + communityId: data.communityId, + communityName: data.communityName, + description: data.description, + groupId: data.groupId, + image: data.image, + }); + } + }); } catch (e) { - _.noop(); + return undefined; } + })).then(() => { + list.sort((a, b) => a.communityName.localeCompare(b.communityName)); + res.json(list); }); - list.sort((a, b) => a.communityName.localeCompare(b.communityName)); - res.json(list); }); /** diff --git a/src/server/tc-communities/qa/metadata.json b/src/server/tc-communities/qa/metadata.json index 609aea6f3a..1165d72b6d 100644 --- a/src/server/tc-communities/qa/metadata.json +++ b/src/server/tc-communities/qa/metadata.json @@ -1,6 +1,6 @@ { "challengeFilter": { - "groupIds": ["20000004"] + "groupIds": ["20000004", "20000000"] }, "communityId": "qa", "communityName": "QaaS", diff --git a/src/server/tc-communities/wipro/metadata.json b/src/server/tc-communities/wipro/metadata.json index 68ac2d15dd..55b877350a 100644 --- a/src/server/tc-communities/wipro/metadata.json +++ b/src/server/tc-communities/wipro/metadata.json @@ -1,9 +1,9 @@ { "authorizedGroupIds": [ - "20000000", "20000004" + "20000000", "20000005", "200000011" ], "challengeFilter": { - "groupIds": ["20000000"] + "groupIds": ["20000000", "20000004", "20000005", "20000007", "20000008", "20000009", "200000011"] }, "challengeListing": { "openChallengesInNewTabs": true diff --git a/src/shared/actions/groups.js b/src/shared/actions/groups.js new file mode 100644 index 0000000000..32b0235c42 --- /dev/null +++ b/src/shared/actions/groups.js @@ -0,0 +1,54 @@ +/** + * Actions related to user groups. + * + * TODO: Some group-related actions can be found elsewhere (e.g. addition of + * members to group is located inside tc-communities actions, because joining + * a community is equivalent to adding user to a group). It will be great to + * move such actions in here. + */ + +import { createActions } from 'redux-actions'; +import { getService } from 'services/groups'; + +/* This pair of action creators allows to load detailed information about the + * specified user group. This information includes data on the sub-groups, thus + * related actions effective load entire tree of groups descendant from the + * specified one. + * + * Related segment of the Redux state is designed to keep information about + * many user groups at once. Thus, loading data about new groups does not drop + * previously loaded data on other groups (but, if previously loaded group is + * loaded again, related data are properly updated). Also, all requests to load + * user group data are handled in parallel, and new requests do not cancel + * previous ones, even if they have no been finished yet. */ + +/** + * Initiates loading of the user group data. Created action writes groupId into + * Redux state for book-keeping purposes. This action does not cancel previous + * unfinished requests to load group data. + * @param {String} groupId + * @return {String} + */ +function getInit(groupId) { + return groupId; +} + +/** + * Actually loads user group data. Note that a proper v3 auth token is necessary + * to get user group data. + * @param {String} groupId + * @param {String} tokenV3 + * @return {Object} + */ +function getDone(groupId, tokenV3) { + return getService(tokenV3).get(groupId) + .then(result => ({ groupId, result })) + .catch(error => ({ groupId, error })); +} + +export default createActions({ + GROUPS: { + GET_INIT: getInit, + GET_DONE: getDone, + }, +}); diff --git a/src/shared/actions/tc-communities/index.js b/src/shared/actions/tc-communities/index.js index 03d2ea5b85..4e95588e97 100644 --- a/src/shared/actions/tc-communities/index.js +++ b/src/shared/actions/tc-communities/index.js @@ -28,8 +28,9 @@ function getList(auth) { 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))); + return fetch(`/api/tc-communities?${qs.stringify({ groups })}`, { + credentials: 'same-origin', + }).then(res => (res.ok ? res.json() : new Error(res.statusText))); } export default createActions({ diff --git a/src/shared/components/Modal/index.jsx b/src/shared/components/Modal/index.jsx index 5f46167eab..f19c0ff4fd 100644 --- a/src/shared/components/Modal/index.jsx +++ b/src/shared/components/Modal/index.jsx @@ -6,27 +6,48 @@ * callback passed from the parent. */ +/* global document */ + +import _ from 'lodash'; import React from 'react'; import PT from 'prop-types'; -import _ from 'lodash'; import { themr } from 'react-css-super-themr'; import defaultStyle from './styles.scss'; -function Modal(props) { - return ( -
-
event.preventDefault()} - >{props.children}
-
- ); +/* NOTE: Modal component is implemented as class because we should append / + * remove a special class to the document's body to block its scrolling while + * keeping the modal's content scrollable. Unfortunately, just catching and + * manipulating on mouse wheel events does not help. */ +class Modal extends React.Component { + componentDidMount() { + document.body.classList.add('scrolling-disabled-by-modal'); + } + + componentWillUnmount() { + document.body.classList.remove('scrolling-disabled-by-modal'); + } + + render() { + const { + children, + onCancel, + theme, + } = this.props; + return ( +
+
event.stopPropagation()} + >{children}
+
+ ); + } } + Modal.defaultProps = { onCancel: _.noop, children: null, diff --git a/src/shared/components/Modal/styles.scss b/src/shared/components/Modal/styles.scss index aa04ba23d8..bc74b8aabc 100644 --- a/src/shared/components/Modal/styles.scss +++ b/src/shared/components/Modal/styles.scss @@ -1,5 +1,12 @@ -@import '~styles/tc-styles'; -$button-space-32: $base-unit * 6 + 2; +@import '~styles/mixins'; + +$button-space-32: 6 * $base-unit; + +:global { + body.scrolling-disabled-by-modal { + overflow: hidden; + } +} .overlay { background: $tc-gray-neutral-dark; @@ -11,25 +18,29 @@ $button-space-32: $base-unit * 6 + 2; position: fixed; top: 0; width: 100%; - z-index: 99; + z-index: 998; } .container { background: #fff; box-shadow: 0 0 14px 1px rgba(38, 38, 40, 0.15); - border-radius: 4px; - padding: 40px; - width: 465px; + border-radius: 2 * $corner-radius; + max-height: 95vh; + max-width: $screen-md; + overflow: hidden; + padding: 8 * $base-unit; + width: 480px; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 999; + @include xxs-to-md { + max-width: 95vw; + } + @include xxs-to-xs { - width: 92%; - left: 4%; - transform: translate(0, -50%); padding: 40px 10px; } } diff --git a/src/shared/components/SubmissionManagement/Submission/index.jsx b/src/shared/components/SubmissionManagement/Submission/index.jsx index a8ec029a3b..de9b7b3724 100644 --- a/src/shared/components/SubmissionManagement/Submission/index.jsx +++ b/src/shared/components/SubmissionManagement/Submission/index.jsx @@ -43,7 +43,7 @@ export default function Submission(props) { styleName={type === 'DESIGN' ? 'design-img' : 'dev-img'} src={ submissionObject.preview || - `${config.URL.STUDIO}?module=DownloadSubmission&sbmid=${submissionObject.submissionId}&sbt=tiny` + `${config.URL.STUDIO}?module=DownloadSubmission&sbmid=${submissionObject.submissionId}&sbt=tiny&sfi=1` } /> diff --git a/src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.jsx b/src/shared/components/challenge-detail/ChallengeTerms/TermDetails.jsx similarity index 57% rename from src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.jsx rename to src/shared/components/challenge-detail/ChallengeTerms/TermDetails.jsx index 54c6047529..3e754e01d5 100644 --- a/src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.jsx +++ b/src/shared/components/challenge-detail/ChallengeTerms/TermDetails.jsx @@ -3,10 +3,9 @@ import React from 'react'; import PT from 'prop-types'; import cn from 'classnames'; -import { PrimaryButton, Button } from 'components/buttons'; import LoadingIndicator from 'components/LoadingIndicator'; -import style from './TermDetails.scss'; +import './TermDetails.scss'; export default class TermDetails extends React.Component { constructor(props) { @@ -32,38 +31,18 @@ export default class TermDetails extends React.Component { } render() { - const { details, docuSignUrl, agreeingTerm, agreeTerm, closeModal, - loadingDocuSignUrl, viewOnly, agreed, nextTerm } = this.props; + const { details, docuSignUrl, + loadingDocuSignUrl } = this.props; return (
{ details.agreeabilityType === 'Electronically-agreeable' &&
-
- { - !viewOnly && -
- { - agreed ? - (Next) : - (
- agreeTerm(details.termsOfUseId)} - theme={style} - >I Agree - -
) - } -
- } +
} { @@ -90,19 +69,12 @@ export default class TermDetails extends React.Component { TermDetails.defaultProps = { details: {}, docuSignUrl: '', - agreeingTerm: '', loadingDocuSignUrl: '', }; TermDetails.propTypes = { details: PT.shape(), docuSignUrl: PT.string, - agreeTerm: PT.func.isRequired, - agreeingTerm: PT.string, - closeModal: PT.func.isRequired, loadingDocuSignUrl: PT.string, getDocuSignUrl: PT.func.isRequired, - viewOnly: PT.bool.isRequired, - agreed: PT.bool.isRequired, - nextTerm: PT.func.isRequired, }; diff --git a/src/shared/components/challenge-detail/ChallengeTerms/TermDetails.scss b/src/shared/components/challenge-detail/ChallengeTerms/TermDetails.scss new file mode 100644 index 0000000000..7bb1410b46 --- /dev/null +++ b/src/shared/components/challenge-detail/ChallengeTerms/TermDetails.scss @@ -0,0 +1,15 @@ +@import "~styles/mixins"; + +.body { + border: 1px solid $tc-gray-30; + word-break: break-word; +} + +.frame { + height: 1420px; + width: 100%; + + &.loading { + display: none; + } +} diff --git a/src/shared/components/challenge-detail/ChallengeTerms/index.jsx b/src/shared/components/challenge-detail/ChallengeTerms/index.jsx new file mode 100644 index 0000000000..0f548f3536 --- /dev/null +++ b/src/shared/components/challenge-detail/ChallengeTerms/index.jsx @@ -0,0 +1,305 @@ +/* eslint jsx-a11y/no-static-element-interactions:0 */ +/* global window */ + +import _ from 'lodash'; +import React from 'react'; +import PT from 'prop-types'; +import cn from 'classnames'; +import Modal from 'components/Modal'; +import { PrimaryButton, Button } from 'components/buttons'; +import LoadingIndicator from 'components/LoadingIndicator'; +import TermDetails from './TermDetails'; + +import style from './styles.scss'; + +function handleScroll(scrollElement, masks, orientation) { + let length; + let base; + let clientSize; + if (orientation === 'vertical') { + length = 'scrollHeight'; + base = 'scrollTop'; + clientSize = 'clientHeight'; + } else { + clientSize = 'clientWidth'; + length = 'scrollWidth'; + base = 'scrollLeft'; + } + const mask1 = masks[0]; + const mask2 = masks[1]; + // When the scrollbar reaches end, disable mask2. + if (scrollElement[length] - scrollElement[base] === scrollElement[clientSize]) { + mask2.style.display = 'none'; + } else if (scrollElement[base] === 0) { + // At the beginning, disable mask1. + mask1.style.display = 'none'; + } else { + // Show both masks in between. + mask1.style.display = 'block'; + mask2.style.display = 'block'; + if (orientation === 'vertical') { + mask1.style.top = `${scrollElement[base]}px`; + mask2.style.bottom = `${-scrollElement[base]}px`; + } + } +} + +export default class ChallengeTerms extends React.Component { + constructor(props) { + super(props); + + this.selectTerm = this.selectTerm.bind(this); + this.messageHandler = this.messageHandler.bind(this); + this.resizeHandler = this.resizeHandler.bind(this); + this.nextTerm = this.nextTerm.bind(this); + this.max = 0; + } + + componentDidMount() { + const { loadDetails, selectedTerm } = this.props; + if (selectedTerm) { + loadDetails(selectedTerm.termsOfUseId); + } + window.addEventListener('message', this.messageHandler, false); + window.addEventListener('resize', this.resizeHandler, false); + } + + componentWillReceiveProps(nextProps) { + const { selectedTerm, loadDetails, terms, + checkStatus, canRegister, onCancel, register } = this.props; + if (nextProps.selectedTerm && !_.isEqual(selectedTerm, nextProps.selectedTerm) && + nextProps.loadingTermId !== _.toString(nextProps.selectedTerm.termsOfUseId)) { + loadDetails(nextProps.selectedTerm.termsOfUseId); + } + if (!_.every(terms, 'agreed') && _.every(nextProps.terms, 'agreed') && !nextProps.checkingStatus) { + checkStatus(); + } + if (!canRegister && nextProps.canRegister) { + onCancel(); + register(); + } + } + + componentWillUnmount() { + window.removeEventListener('message', this.messageHandler); + window.removeEventListener('resize', this.resizeHandler); + } + + selectTerm(term) { + const { selectTerm, selectedTerm } = this.props; + if (selectedTerm !== term) { + selectTerm(term); + } + } + + nextTerm() { + const { terms, selectTerm } = this.props; + const term = _.find(terms, t => !t.agreed); + selectTerm(term); + } + + messageHandler(event) { + const { onCancel, selectedTerm, signDocu } = this.props; + if (event.data.type === 'DocuSign') { + if (event.data.event === 'signing_complete') { + signDocu(selectedTerm.termsOfUseId); + } else { + onCancel(); + } + } + } + + resizeHandler() { + const cname = style['mask-h']; + const masks = document.getElementsByClassName(cname); + if (this.hScrollElement.scrollWidth === this.hScrollElement.clientWidth) { + // eslint-disable-next-line no-param-reassign + _.forEach(masks, (m) => { m.style.display = 'none'; }); + } else { + // set the mask style if need + handleScroll(this.hScrollElement, masks, 'horizonal'); + } + } + + render() { + const { onCancel, terms, details, loadingTermId, docuSignUrl, + getDocuSignUrl, agreeTerm, agreeingTerm, isLoadingTerms, + loadingDocuSignUrl, selectedTerm, viewOnly, checkingStatus } = this.props; + + const handleHorizonalScroll = (e) => { + const scrollElement = e.target; + + const cname = style['mask-h']; + /* eslint-env browser */ + const masks = document.getElementsByClassName(cname); + handleScroll(scrollElement, masks, 'horizonal'); + }; + + const handleVerticalScroll = (e) => { + const scrollElement = e.target; + + const cname = style['mask-v']; + /* eslint-env browser */ + const masks = document.getElementsByClassName(cname); + handleScroll(scrollElement, masks, 'vertical'); + }; + + return ( +
+ + { + isLoadingTerms && + + } + { + !isLoadingTerms && ( +
+
{terms.length > 1 ? 'Terms & Conditions of Use' : terms[0].title}
+
{ this.vScrollArea = node; }} + styleName="scrollable-area" + > +
+
+
You are seeing these Terms & Conditions because you have registered to a challenge and + you have to respect the terms below in order to be able to submit.
+ { + checkingStatus && + + } + { + !checkingStatus && terms.length > 1 && +
+
+
+
{ this.hScrollElement = e; }}> + { + terms.map((t, index) => ( +
+
this.selectTerm(t)}>{index + 1}
+
this.selectTerm(t)}>{t.title}
+ { + index < terms.length - 1 && +
+ } +
+ )) + } +
+
+ } + { + !checkingStatus && selectedTerm && +
+ { + terms.length > 1 &&
{selectedTerm.title}
+ } + { + loadingTermId === _.toString(selectedTerm.termsOfUseId) && + + } + { + loadingTermId !== _.toString(selectedTerm.termsOfUseId) && details && + + } +
+ } +
{ /* The end of scrollable area */ } + { + !isLoadingTerms && !checkingStatus && selectedTerm && details + && !viewOnly && + loadingTermId !== _.toString(selectedTerm.termsOfUseId) && + details.agreeabilityType === 'Electronically-agreeable' ? ( +
+ { + selectedTerm.agreed ? + ( { + this.nextTerm(e); + if (this.vScrollArea) { + this.vScrollArea.scrollTop = 0; + } + }} + >Next) : + (
+ { + agreeTerm(details.termsOfUseId); + if (this.vScrollArea) { + this.vScrollArea.scrollTop = 0; + } + }} + theme={style} + >I Agree + +
) + } +
+ ) :
+ } +
+ ) + } + +
+ ); + } +} + +ChallengeTerms.defaultProps = { + terms: [], + title: '', + details: {}, + loadingTermId: '', + docuSignUrl: '', + agreeingTerm: '', + isLoadingTerms: false, + registering: false, + loadingDocuSignUrl: '', + selectedTerm: null, + viewOnly: false, +}; + +ChallengeTerms.propTypes = { + onCancel: PT.func.isRequired, + terms: PT.arrayOf(PT.shape()), + loadDetails: PT.func.isRequired, + details: PT.shape(), + loadingTermId: PT.string, + docuSignUrl: PT.string, + getDocuSignUrl: PT.func.isRequired, + register: PT.func.isRequired, + agreeTerm: PT.func.isRequired, + agreeingTerm: PT.string, + isLoadingTerms: PT.bool, + loadingDocuSignUrl: PT.string, + selectedTerm: PT.shape(), + checkStatus: PT.func.isRequired, + canRegister: PT.bool.isRequired, + checkingStatus: PT.bool.isRequired, + signDocu: PT.func.isRequired, + selectTerm: PT.func.isRequired, + viewOnly: PT.bool, +}; diff --git a/src/shared/components/challenge-detail/ChallengeTerms/styles.scss b/src/shared/components/challenge-detail/ChallengeTerms/styles.scss new file mode 100644 index 0000000000..ba7f76747f --- /dev/null +++ b/src/shared/components/challenge-detail/ChallengeTerms/styles.scss @@ -0,0 +1,246 @@ +@import "~styles/mixins"; + +/* Fills the space in the bottom of the modal, when we do not need to render + * buttons there. */ +.bottom-placeholder { + height: 5 * $base-unit; + + @include xxs-to-xs { + height: 3 * $base-unit; + } +} + +.container { + font-family: roboto; +} + +.modal-container { + display: flex; + flex-direction: column; + height: 95vh; + padding: 40px 40px 0; + width: 100%; + + @include xxs-to-xs { + padding: 3 * $base-unit; + padding-bottom: 0; + } +} + +.modal-content { + display: flex; + flex: 1; + flex-direction: column; +} + +.scrollable-area { + flex: 1; + overflow-y: auto; + position: relative; +} + +.title { + font-size: 28px; + margin-bottom: 1.5 * $base-unit; + text-align: center; +} + +// TODO: All classes below should be sorted alphabetically for convenience. + +.desc { + margin-top: 24px; + font-size: 15px; + line-height: 25px; + + @include xs { + margin-top: 20px; + } +} + +.tabs-outer { + position: relative; + margin: 20px 0 25px; +} + +.tabs-inner { + display: flex; + + @include xs { + width: 100%; + display: inline-block; + white-space: nowrap; + font-size: 0; + overflow-x: auto; + overflow-y: hidden; + } +} + +.tab { + display: flex; + align-items: center; + flex: 1; + + @include xs { + width: 50%; + display: inline-block; + + &.last { + width: auto; + } + } + + &.agreed, + &.active.view-only { + .tab-index { + background-color: $tc-dark-blue-90; + } + + .tab-bar { + background-color: $tc-dark-blue-90; + } + } + + &.active { + .tab-index { + background-color: $tc-green; + } + } + + &.last { + flex-grow: 0; + flex-shrink: 0; + } +} + +.tab-index { + width: 26px; + height: 26px; + border-radius: 100%; + color: $tc-white; + cursor: pointer; + font-size: 13px; + font-weight: 700; + line-height: 26px; + text-align: center; + background-color: $tc-gray-30; + flex-shrink: 0; + + @include xs { + display: inline-block; + vertical-align: middle; + } +} + +.tab-title { + margin-left: 5px; + cursor: pointer; + font-size: 13px; + max-width: 95px; + min-width: 70px; + + @include xs { + display: inline-block; + vertical-align: middle; + white-space: normal; + } +} + +.tab-bar { + margin: 0 10px; + background-color: $tc-gray-20; + height: 2px; + flex: 1; + + @include xs { + display: inline-block; + max-width: calc(100% - 120px); + min-width: calc(100% - 145px); + vertical-align: middle; + } +} + +.labels { + margin-bottom: 40px; + display: flex; + + .label { + flex: 1; + cursor: pointer; + left: 0; + top: 12px; + font-size: 13px; + overflow: hidden; + + @include xs { + flex: 0 0 135px; + } + } +} + +.single { + padding-top: 25px; +} + +.sub-title { + line-height: 30px; + font-size: 20px; + margin-bottom: 10px; + + @include xs { + padding: 0 15px; + } +} + +.button-container { + margin-top: 20px; + display: flex; + justify-content: space-around; +} + +.buttons { + padding: (1.5 * $base-unit) 0 (2 * $base-unit); + text-align: center; +} + +.button { + height: 30px; + padding: 0 15px; + font-size: 13px; + line-height: 20px; +} + +.mask-h { + height: 100%; + position: absolute; + width: 30px; + z-index: 100; + display: none; +} + +.mask-h.left { + background: linear-gradient(-90deg, transparent, $tc-white 100%); + left: 0; +} + +.mask-h.right { + background: linear-gradient(90deg, transparent, $tc-white 100%); + right: 0; +} + +.mask-v { + width: 100%; + position: absolute; + height: 30px; + z-index: 100; + display: none; +} + +.mask-v.top { + background: linear-gradient(0deg, transparent, $tc-white 100%); + top: 0; +} + +.mask-v.bottom { + background: linear-gradient(180deg, transparent, $tc-white 100%); + bottom: 0; +} diff --git a/src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.scss b/src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.scss deleted file mode 100644 index 28eccc10c2..0000000000 --- a/src/shared/components/challenge-detail/Specification/TermsModal/TermDetails.scss +++ /dev/null @@ -1,38 +0,0 @@ -@import "~styles/tc-styles"; - -.body { - max-height: 480px; - overflow: auto; - border: 1px solid $tc-gray-30; - word-break: break-word; - - @include xs { - max-height: none; - padding: 10px 0; - border: none; - background-color: $tc-white; - } -} - -.frame { - height: 480px; - width: 100%; - - &.loading { - display: none; - } -} - -.buttons { - display: flex; - justify-content: space-around; - margin-top: 20px; - padding: 0 50px; -} - -.button { - height: 30px; - padding: 4px 15px; - font-size: 13px; - line-height: 20px; -} diff --git a/src/shared/components/challenge-detail/Specification/TermsModal/index.jsx b/src/shared/components/challenge-detail/Specification/TermsModal/index.jsx deleted file mode 100644 index 521642b52b..0000000000 --- a/src/shared/components/challenge-detail/Specification/TermsModal/index.jsx +++ /dev/null @@ -1,221 +0,0 @@ -/* eslint jsx-a11y/no-static-element-interactions:0 */ -/* global window */ - -import _ from 'lodash'; -import React from 'react'; -import PT from 'prop-types'; -import cn from 'classnames'; -import Modal from 'components/Modal'; -import { PrimaryButton, Button } from 'components/buttons'; -import LoadingIndicator from 'components/LoadingIndicator'; -import TermDetails from './TermDetails'; - -import styles from './styles.scss'; - - -export default class TermsModal extends React.Component { - constructor(props) { - super(props); - - this.selectTerm = this.selectTerm.bind(this); - this.messageHandler = this.messageHandler.bind(this); - this.nextTerm = this.nextTerm.bind(this); - this.max = 0; - } - - componentDidMount() { - const { loadDetails, selectedTerm } = this.props; - if (selectedTerm) { - loadDetails(selectedTerm.termsOfUseId); - } - window.addEventListener('message', this.messageHandler, false); - } - - componentWillReceiveProps(nextProps) { - const { selectedTerm, loadDetails, terms, - checkStatus, canRegister, onCancel, register } = this.props; - if (nextProps.selectedTerm && !_.isEqual(selectedTerm, nextProps.selectedTerm) && - nextProps.loadingTermId !== _.toString(nextProps.selectedTerm.termsOfUseId)) { - loadDetails(nextProps.selectedTerm.termsOfUseId); - } - if (!_.every(terms, 'agreed') && _.every(nextProps.terms, 'agreed') && !nextProps.checkingStatus) { - checkStatus(); - } - if (!canRegister && nextProps.canRegister) { - onCancel(); - register(); - } - } - - componentWillUnmount() { - window.removeEventListener('message', this.messageHandler); - } - - selectTerm(term) { - const { selectTerm, selectedTerm } = this.props; - if (selectedTerm !== term) { - selectTerm(term); - } - } - - nextTerm() { - const { terms, selectTerm } = this.props; - const term = _.find(terms, t => !t.agreed); - selectTerm(term); - } - - messageHandler(event) { - const { onCancel, selectedTerm, signDocu } = this.props; - if (event.data.type === 'DocuSign') { - if (event.data.event === 'signing_complete') { - signDocu(selectedTerm.termsOfUseId); - } else { - onCancel(); - } - } - } - - render() { - const { onCancel, terms, details, loadingTermId, docuSignUrl, - getDocuSignUrl, agreeTerm, agreeingTerm, isLoadingTerms, - loadingDocuSignUrl, selectedTerm, viewOnly, checkingStatus } = this.props; - - return ( -
- - { - isLoadingTerms && - - } - { - !isLoadingTerms && -
-
{terms.length > 1 ? 'Terms & Conditions of Use' : terms[0].title}
-
You are seeing these Terms & Conditions because you have registered to a challenge and - you have to respect the terms below in order to be able to submit.
- { - checkingStatus && - - } - { - !checkingStatus && terms.length > 1 && -
-
-
- { - terms.map(t => ( -
- {!viewOnly &&
} -
- )) - } -
-
-
- { - terms.map(t => ( -
this.selectTerm(t)} key={t.termsOfUseId}>{t.title}
- )) - } -
-
- } -
- } - { - !isLoadingTerms && !checkingStatus && selectedTerm && -
- { - terms.length > 1 &&
{selectedTerm.title}
- } - { - loadingTermId === _.toString(selectedTerm.termsOfUseId) && - - } - { - loadingTermId !== _.toString(selectedTerm.termsOfUseId) && details && - - } -
- } - - - { - !isLoadingTerms && !checkingStatus && selectedTerm && details && !viewOnly && - loadingTermId !== _.toString(selectedTerm.termsOfUseId) && - details.agreeabilityType === 'Electronically-agreeable' && -
- { - selectedTerm.agreed ? - (Next) : - (
- agreeTerm(details.termsOfUseId)} - theme={styles} - >I Agree - -
) - } -
- } -
- ); - } -} - -TermsModal.defaultProps = { - terms: [], - title: '', - details: {}, - loadingTermId: '', - docuSignUrl: '', - agreeingTerm: '', - isLoadingTerms: false, - registering: false, - loadingDocuSignUrl: '', - selectedTerm: null, - viewOnly: false, -}; - -TermsModal.propTypes = { - onCancel: PT.func.isRequired, - terms: PT.arrayOf(PT.shape()), - loadDetails: PT.func.isRequired, - details: PT.shape(), - loadingTermId: PT.string, - docuSignUrl: PT.string, - getDocuSignUrl: PT.func.isRequired, - register: PT.func.isRequired, - agreeTerm: PT.func.isRequired, - agreeingTerm: PT.string, - isLoadingTerms: PT.bool, - loadingDocuSignUrl: PT.string, - selectedTerm: PT.shape(), - checkStatus: PT.func.isRequired, - canRegister: PT.bool.isRequired, - checkingStatus: PT.bool.isRequired, - signDocu: PT.func.isRequired, - selectTerm: PT.func.isRequired, - viewOnly: PT.bool, -}; diff --git a/src/shared/components/challenge-detail/Specification/TermsModal/styles.scss b/src/shared/components/challenge-detail/Specification/TermsModal/styles.scss deleted file mode 100644 index 6d8121fcf9..0000000000 --- a/src/shared/components/challenge-detail/Specification/TermsModal/styles.scss +++ /dev/null @@ -1,173 +0,0 @@ -@import "~styles/tc-styles"; - -.container { - font-family: roboto; -} - -.title { - line-height: 40px; - font-size: 28px; - text-align: center; - - @include xs { - text-align: left; - } -} - -.desc { - margin-top: 60px; - font-size: 15px; - line-height: 25px; - - @include xs { - margin-top: 20px; - } -} - -.tabs-labels { - overflow-x: auto; -} - -.tabs-outer { - margin-top: 30px; - margin-bottom: 4px; - height: 10px; - border-radius: 5px; -} - -.tabs-inner { - display: flex; - border-radius: 5px; - overflow: hidden; - background-color: $tc-gray-20; - - @include xs { - width: auto; - display: inline-block; - white-space: nowrap; - height: 10px; - font-size: 0; - } -} - -.tab { - flex: 1; - height: 10px; - position: relative; - - @include xs { - width: 135px; - display: inline-block; - } - - &.agreed, - &.active.view-only { - background-color: $tc-green-70; - } - - &.agreed, - &.active { - .indicator { - background-color: $tc-green-70; - } - } - - .indicator { - height: 10px; - width: 10px; - border-radius: 100%; - background-color: $tc-gray-30; - } -} - -.labels { - margin-bottom: 40px; - display: flex; - - .label { - flex: 1; - cursor: pointer; - left: 0; - top: 12px; - font-size: 13px; - overflow: hidden; - - @include xs { - flex: 0 0 135px; - } - } -} - -.single { - padding-top: 25px; -} - -.sub-title { - line-height: 30px; - font-size: 20px; - margin-bottom: 10px; - - @include xs { - padding: 0 15px; - } -} - -.modal-container { - width: 1167px; - max-height: 965px; - padding: 60px 64px 20px 56px; - - @include md { - width: 960px; - } - - @include sm { - width: 720px; - padding: 20px; - } - - @include xs { - width: 300px; - padding: 0; - max-height: 80%; - overflow-y: auto; - top: calc(50% - 50px); - } -} - -.top-section { - @include xs { - padding: 15px; - } -} - -.button-container { - margin-top: 20px; - display: flex; - justify-content: space-around; -} - -.buttons { - display: flex; - justify-content: space-around; - margin-top: 20px; - padding: 5px; - background-color: $tc-white; - - @include xs { - position: fixed; - bottom: 0; - height: 50px; - margin-top: 0; - left: 0; - right: 0; - z-index: 999999; - } -} - -.button { - height: 30px; - padding: 4px 15px; - font-size: 13px; - line-height: 20px; -} diff --git a/src/shared/components/tc-communities/JoinCommunity/ConfirmModal/index.jsx b/src/shared/components/tc-communities/JoinCommunity/ConfirmModal/index.jsx index edd4a6c056..0cf3de10c5 100644 --- a/src/shared/components/tc-communities/JoinCommunity/ConfirmModal/index.jsx +++ b/src/shared/components/tc-communities/JoinCommunity/ConfirmModal/index.jsx @@ -26,10 +26,14 @@ export default function ConfirmModal({ return (
- { userId ? null : ( -

You must be a Topcoder member before you can join the {communityName}.

+ { !userId ? ( +

Do you want to join {communityName}?

+ ) : ( +
+

You must be a Topcoder member before you can join the {communityName}.

+

To join, login if you are already a member. If not, register first.

+
)} - To join, login if you are already a member. If not, register first.
{ userId ? ( diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 42d6c98c60..8627bc42d7 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -15,7 +15,7 @@ import Registrants from 'components/challenge-detail/Registrants'; import Submissions from 'components/challenge-detail/Submissions'; import Winners from 'components/challenge-detail/Winners'; import ChallengeDetailsView from 'components/challenge-detail/Specification'; -import TermsModal from 'components/challenge-detail/Specification/TermsModal'; +import ChallengeTerms from 'components/challenge-detail/ChallengeTerms'; import ChallengeCheckpoints from 'components/challenge-detail/Checkpoints'; import React from 'react'; import PT from 'prop-types'; @@ -227,7 +227,7 @@ class ChallengeDetailPageContainer extends React.Component {
{ this.props.showTermsModal && - - item.id === state.tcCommunities.meta.groupId); + let canJoin = !state.auth.profile || !state.auth.profile.groups; + if (!canJoin) { + canJoin = !isGroupMember(state.tcCommunities.meta.groupId, + state.auth.profile.groups, state.groups.groups); + } if (state.tcCommunities.hideJoinButton) canJoin = false; if (canJoin) canJoin = state.tcCommunities.joinCommunityButton; diff --git a/src/shared/containers/tc-communities/Loader.jsx b/src/shared/containers/tc-communities/Loader.jsx index e90eda436b..b0907f6554 100644 --- a/src/shared/containers/tc-communities/Loader.jsx +++ b/src/shared/containers/tc-communities/Loader.jsx @@ -13,12 +13,14 @@ import AccessDenied, { } from 'components/tc-communities/AccessDenied'; import actions from 'actions/tc-communities/meta'; import config from 'utils/config'; +import groupActions from 'actions/groups'; import LoadingPagePlaceholder from 'components/tc-communities/LoadingPagePlaceholder'; import PT from 'prop-types'; import React from 'react'; import { connect } from 'react-redux'; +import { isGroupMember } from 'utils/tc'; /** * When community Loader is mounted, and when it receives new props, these are @@ -37,15 +39,16 @@ class Loader extends React.Component { } componentWillReceiveProps(nextProps) { - const { communityId, loadingMetaDataForCommunityId, meta } = nextProps; + const { communityId, loadingCommunityData, meta } = nextProps; /* We reload community meta-data when: * - No metadata is loaded or being loaded for the specified community; * - Already loaded metadata have been loaded more than MAXAGE ago. */ - if ((communityId !== loadingMetaDataForCommunityId) && ( + if (!loadingCommunityData && ( !meta || meta.communityId !== communityId || (Date.now() - meta.lastUpdateOfMetaData) > MAXAGE - )) this.props.loadMetaData(communityId); + || this.missingApiGroup() + )) this.props.loadMetaData(communityId, nextProps.tokenV3); /* TODO: This is a hacky way to handle SSO authentication for TopGear * (Wipro) community visitors. Should be re-factored, but not it is not @@ -56,18 +59,38 @@ class Loader extends React.Component { } } + /** + * Assuming that community meta-data has been loaded, this method verifies + * that detailed information about all related user groups also have been + * loaded into. + */ + missingApiGroup() { + const { apiGroups, meta } = this.props; + if (!meta) return false; + if (meta.groupId && !apiGroups[meta.groupId]) return true; + if (meta.authorizedGroupIds && meta.authorizedGroupIds.some(id => + !apiGroups[id])) return true; + return false; + } + render() { - const { Community, communityId, meta, visitorGroups } = this.props; + const { + apiGroups, + Community, + communityId, + loadingCommunityData, + meta, + visitorGroups, + } = this.props; /* Community meta-data are still being loaded. */ - if (!meta) { + if (loadingCommunityData || !meta) { return ; } - const visitorGroupIds = visitorGroups ? visitorGroups.map(g => g.id) : null; - const member = visitorGroupIds && meta.groupId - && visitorGroupIds.includes(meta.groupId); + const member = visitorGroups && meta.groupId + && isGroupMember(meta.groupId, visitorGroups, apiGroups); /* Community does not require authorization. */ if (!meta.authorizedGroupIds) return Community({ member, meta }); @@ -85,7 +108,8 @@ class Loader extends React.Component { /* Visitor belongs to at least one of the groups authorized to access this * community. */ - if (_.intersection(visitorGroupIds, meta.authorizedGroupIds).length) { + // if (_.intersection(visitorGroupIds, meta.authorizedGroupIds).length) { + if (isGroupMember(meta.authorizedGroupIds, visitorGroups, apiGroups)) { return Community({ member, meta }); } @@ -96,43 +120,97 @@ class Loader extends React.Component { Loader.defaultProps = { meta: null, + tokenV3: '', visitorGroups: null, }; Loader.propTypes = { + apiGroups: PT.shape().isRequired, communityId: PT.string.isRequired, Community: PT.func.isRequired, - loadingMetaDataForCommunityId: PT.string.isRequired, + loadingCommunityData: PT.bool.isRequired, loadMetaData: PT.func.isRequired, meta: PT.shape({ authorizedGroupIds: PT.arrayOf(PT.string), communityId: PT.string.isRequired, }), + tokenV3: PT.string, visitorGroups: PT.arrayOf(PT.shape({ id: PT.string.isRequired })), }; +/** + * Tests whether we are currently loading data for the specified community. + * The data include: + * - Community meta-data; + * - Detailed information about all user groups referrenced in the meta-data. + * @param {String} communityId Community ID to be tested. + * @param {Object} state Redux state. + * @return {Boolean} "true" if the data are being loaded now; "false" otherwise. + */ +function isLoading(communityId, state) { + const meta = state.tcCommunities.meta; + + /* Yes, we are currently loading meta data for the specified community. */ + if (communityId === meta.loadingMetaDataForCommunityId) return true; + + /* Another community is loaded, thus we surely are not loading the specified + * one. */ + if (communityId !== meta.communityId) return false; + + /* If we reached this point, it means that meta-data for the specified + * community have been loaded; but we may still be loading detailed data + * on the related user groups, so we should check it as well. */ + if (meta.groupId && state.groups.loading[meta.groupId]) return true; + if (meta.authorizedGroupIds && meta.authorizedGroupIds.some(id => + state.groups.loading[id])) return true; + + /* Finally, if we are here, it means that all information relevant to the + * specified community is loaded into the state now. */ + return false; +} + function mapStateToProps(state, ownProps) { const communityId = ownProps.communityId; let meta = state.tcCommunities.meta; - const loadingMetaDataForCommunityId = meta.loadingMetaDataForCommunityId; if (meta.communityId !== communityId) meta = null; return { + apiGroups: state.groups.groups, communityId, Community: ownProps.communityComponent, - loadingMetaDataForCommunityId, + loadingCommunityData: isLoading(communityId, state), meta, + tokenV3: _.get(state, 'auth.tokenV3'), visitorGroups: _.get(state, 'auth.profile.groups'), }; } function mapDispatchToProps(dispatch) { const a = actions.tcCommunities.meta; + const ga = groupActions.groups; return { - loadMetaData: (communityId) => { + loadMetaData: (communityId, tokenV3) => { dispatch(a.fetchDataInit(communityId)); - dispatch(a.fetchDataDone(communityId)); + + /* From this container's point of view, loading of detailed information + * on all user groups referenced in the community meta-data, is also a + * part of meta-data loading. */ + const action = a.fetchDataDone(communityId); + action.payload.then((res) => { + if (res.authorizedGroupIds) { + res.authorizedGroupIds.forEach((id) => { + dispatch(ga.getInit(id)); + dispatch(ga.getDone(id, tokenV3)); + }); + } + if (res.groupId) { + dispatch(ga.getInit(res.groupId)); + dispatch(ga.getDone(res.groupId)); + } + }); + + dispatch(action); }, }; } diff --git a/src/shared/reducers/auth.js b/src/shared/reducers/auth.js index 5086de26b7..3d305a8eea 100644 --- a/src/shared/reducers/auth.js +++ b/src/shared/reducers/auth.js @@ -3,10 +3,10 @@ */ import actions from 'actions/auth'; -import config from 'utils/config'; import { handleActions } from 'redux-actions'; -import { decodeToken, isTokenExpired } from 'tc-accounts'; +import { decodeToken } from 'tc-accounts'; import { toFSA } from 'utils/redux'; +import { getAuthTokens } from 'utils/tc'; /** * Handles actions.auth.loadProfile action. @@ -51,28 +51,14 @@ function create(initialState) { * @return Promise which resolves to the new reducer. */ export function factory(req) { - const cookies = (req && req.cookies) || {}; - - /* If tokens are expired we ignore them, because there is no easy way to - * get fresh tokens at the server side. Not a big deal, they will be - * refreshed at the frontend, once the App is started. */ - - const adt = config.AUTH_DROP_TIME; - - let tokenV2 = cookies.tcjwt; - if (!tokenV2 || isTokenExpired(tokenV2, adt)) tokenV2 = null; - - let tokenV3 = cookies.v3jwt; - if (!tokenV3 || isTokenExpired(tokenV3, adt)) tokenV3 = null; - const state = { + ...getAuthTokens(req), authenticating: true, - tokenV2, - tokenV3, - user: tokenV3 ? decodeToken(tokenV3) : null, + user: null, }; - if (tokenV3) { - return toFSA(actions.auth.loadProfile(tokenV3)).then(res => + if (state.tokenV3) { + state.user = decodeToken(state.tokenV3); + return toFSA(actions.auth.loadProfile(state.tokenV3)).then(res => create(onProfileLoaded(state, res)), ); } diff --git a/src/shared/reducers/challenge.js b/src/shared/reducers/challenge.js index b709a0ca65..a7531f19bd 100644 --- a/src/shared/reducers/challenge.js +++ b/src/shared/reducers/challenge.js @@ -9,6 +9,7 @@ import logger from 'utils/logger'; import { handleActions } from 'redux-actions'; import { combine, toFSA } from 'utils/redux'; +import { getAuthTokens } from 'utils/tc'; import { updateQuery } from 'utils/url'; import mySubmissionsManagement from './my-submissions-management'; @@ -288,10 +289,7 @@ export function factory(req) { /* TODO: For completely server-side rendering it is also necessary to load * terms, etc. */ if (req && req.url.match(/^\/challenges\/\d{8}([?/].*)?$/)) { - const tokens = { - tokenV2: req.cookies.tcjwt, - tokenV3: req.cookies.v3jwt, - }; + const tokens = getAuthTokens(req); const challengeId = req.url.match(/\d+/)[0]; return toFSA(actions.challenge.getDetailsDone(challengeId, tokens.tokenV3, tokens.tokenV2)) .then((details) => { @@ -321,10 +319,7 @@ export function factory(req) { } if (req && req.url.match(/^\/challenges\/\d{8}\/my-submissions/)) { - const tokens = { - tokenV2: req.cookies.tcjwt, - tokenV3: req.cookies.v3jwt, - }; + const tokens = getAuthTokens(req); const challengeId = req.url.match(/\d+/)[0]; return Promise.all([ toFSA(actions.challenge.getDetailsDone(challengeId, tokens.tokenV3, tokens.tokenV2)), diff --git a/src/shared/reducers/groups.js b/src/shared/reducers/groups.js new file mode 100644 index 0000000000..b254661901 --- /dev/null +++ b/src/shared/reducers/groups.js @@ -0,0 +1,131 @@ +/** + * This reducer handles information related to user-groups. + * + * Corresponding segment of the Redux state is designed to have the following + * fields: + * + * groups {Object} - Holds loaded information about user groups. Keys of this + * object are group IDs, and the values are group data object. To keep the state + * flat, and our code efficient; for any group that has sub-groups, subGroups + * field is removed, while subGroupsIds {String[]} field is added, and each + * sub group data object is added to the groups object. + * + * loading {Object} - Holds IDs of the groups being loaded. Removing ID from + * this object will result in silent discard of the data loaded by the + * corresponding GROUPS/GET_DONE action; though such functionality does + * not look really necessary at the moment, thus we do not provide an + * action to really cancel group loading. + */ + +import _ from 'lodash'; +import actions from 'actions/groups'; +import logger from 'utils/logger'; +import { handleActions } from 'redux-actions'; +import { getCommunityId } from 'routes/subdomains'; +import { toFSA } from 'utils/redux'; +import { addGroup, getAuthTokens, getCommunitiesMetadata } from 'utils/tc'; + +/** + * Initiates the loading of data on the specified group. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetInit(state, action) { + const groupId = action.payload; + if (state.loading[groupId]) return state; + return { ...state, loading: { ...state.loading, [groupId]: true } }; +} + +/** + * Finalizes the loading of data on the specified group. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetDone(state, action) { + const { error, groupId, result } = action.payload; + + if (!state.loading[groupId]) return state; + + let groups = _.clone(state.groups); + const loading = _.clone(state.loading); + delete loading[groupId]; + + if (error) { + logger.error('Failed to load data for the group #', groupId, error); + /* Empty group means that it is not known for API (or we failed to load it + * for any other reason). Its presence in the state means, however, that we + * have tried to load it, at least. */ + groups[groupId] = {}; + } else groups = addGroup(groups, result); + + return { ...state, groups, loading }; +} + +/** + * Creates groups reducer with the specified intial state. + * @param {Object} state Optional. If not given, the empty state is assumed. + * @return {Function} Reducer. + */ +function create(state) { + const a = actions.groups; + return handleActions({ + [a.getInit]: onGetInit, + [a.getDone]: onGetDone, + }, _.defaults(state ? _.clone(state) : {}, { + groups: {}, + loading: {}, + })); +} + +/** + * Loads into the state detailed information on the groups related to the + * specified community. + * + * NOTE: This function is intended for the internal use only, it modifies + * "state" argument! + * + * @param {String} communityId + * @param {String} tokenV3 + * @param {Object} state + * @return {Promise} Resolves to the resulting state. + */ +function loadCommunityGroups(communityId, tokenV3, state) { + let res = _.defaults(state, { groups: {}, loading: {} }); + return getCommunitiesMetadata(communityId).then((data) => { + const ids = data.authorizedGroupIds || []; + if (data.groupId) ids.push(data.groupId); + ids.forEach((id) => { res.loading[id] = true; }); + return Promise.all(ids.map(id => + toFSA(actions.groups.getDone(id, tokenV3)) + .then((result) => { res = onGetDone(res, result); }), + )).then(() => res); + }); +} + +/** + * Reducer factory. + * @param {Object} req Optional. ExpressJS HTTP request. If provided, the + * intial state of the reducer will be tailored to the request. + * @return {Promise} Resolves to the reducer. + */ +export function factory(req) { + if (req) { + /* For any location within any TC community we should load detailed + * information about any related user groups. */ + let communityId = getCommunityId(req.subdomains); + if (!communityId && req.url.startsWith('/community')) { + communityId = req.url.split('/')[2]; + } + if (communityId) { + const tokenV3 = getAuthTokens(req).tokenV3; + return loadCommunityGroups(communityId, tokenV3, {}) + .then(res => create(res)); + } + } + return Promise.resolve(create()); +} + +/* Reducer with the default initial state. */ +export default create(); diff --git a/src/shared/reducers/index.js b/src/shared/reducers/index.js index d28a76a2fc..050b80cab9 100644 --- a/src/shared/reducers/index.js +++ b/src/shared/reducers/index.js @@ -20,6 +20,7 @@ import { factory as authFactory } from './auth'; import { factory as challengeFactory } from './challenge'; import { factory as challengeListingFactory } from './challenge-listing'; import { factory as examplesFactory } from './examples'; +import { factory as groupsFactory } from './groups'; import { factory as statsFactory } from './stats'; import { factory as tcCommunitiesFactory } from './tc-communities'; import { factory as leaderboardFactory } from './leaderboard'; @@ -32,6 +33,7 @@ export function factory(req) { auth: authFactory(req), challenge: challengeFactory(req), challengeListing: challengeListingFactory(req), + groups: groupsFactory(req), examples: examplesFactory(req), stats: statsFactory(req), tcCommunities: tcCommunitiesFactory(req), diff --git a/src/shared/reducers/leaderboard.js b/src/shared/reducers/leaderboard.js index 9471e016c3..e374674449 100644 --- a/src/shared/reducers/leaderboard.js +++ b/src/shared/reducers/leaderboard.js @@ -6,6 +6,7 @@ import actions from 'actions/leaderboard'; import metaActions from 'actions/tc-communities/meta'; import { handleActions } from 'redux-actions'; import { toFSA } from 'utils/redux'; +import { getAuthTokens } from 'utils/tc'; /** * Handles leaderboard.fetchLeaderboard action. @@ -54,10 +55,7 @@ export function factory(req) { const match = req && req.url.match(/community\/([^/]+)\/leaderboard/); if (match) { - const tokens = { - tokenV2: req.cookies.tcjwt, - tokenV3: req.cookies.v3jwt, - }; + const tokens = getAuthTokens(req); const communityId = match[1]; // as every community can has its own leaderboard page url // we are trying to get leadeboard page url from community meta data api diff --git a/src/shared/reducers/tc-communities/index.js b/src/shared/reducers/tc-communities/index.js index ae90121bcf..e30173660a 100644 --- a/src/shared/reducers/tc-communities/index.js +++ b/src/shared/reducers/tc-communities/index.js @@ -6,12 +6,12 @@ import _ from 'lodash'; import actions from 'actions/tc-communities'; -import config from 'utils/config'; import logger from 'utils/logger'; import { handleActions } from 'redux-actions'; -import { decodeToken, isTokenExpired } from 'tc-accounts'; +import { decodeToken } from 'tc-accounts'; import { isClientSide } from 'utils/isomorphy'; import { combine, resolveReducers, toFSA } from 'utils/redux'; +import { getAuthTokens } from 'utils/tc'; import { STATE as JOIN_COMMUNITY } from 'components/tc-communities/JoinCommunity'; import { factory as metaFactory } from './meta'; @@ -62,11 +62,7 @@ function create(initialState = {}) { export function factory(req) { let joinPromise; if (req) { - const cookies = req.cookies || {}; - const adt = config.AUTH_DROP_TIME; - let tokenV3 = cookies.v3jwt; - if (!tokenV3 || isTokenExpired(tokenV3, adt)) tokenV3 = null; - + const tokenV3 = getAuthTokens(req).tokenV3; const joinGroupId = req.query && req.query.join; if (joinGroupId && tokenV3) { const user = decodeToken(tokenV3); diff --git a/src/shared/reducers/terms.js b/src/shared/reducers/terms.js index 7dc1ddb897..c15bc21d52 100644 --- a/src/shared/reducers/terms.js +++ b/src/shared/reducers/terms.js @@ -7,6 +7,16 @@ import actions from 'actions/terms'; import logger from 'utils/logger'; import { handleActions } from 'redux-actions'; import { toFSA } from 'utils/redux'; +import { getAuthTokens } from 'utils/tc'; + +/** + * sort terms by agreed status + * @param {Array} terms terms to sort + * @return {Array} sorted terms + */ +function sortTerms(terms) { + return _.sortBy(terms, t => (t.agreed ? 0 : 1)); +} /** * Handles TERMS/GET_TERMS_DONE action. @@ -34,7 +44,8 @@ function onGetTermsDone(state, action) { return { ...state, - ...action.payload, + challengeId: action.payload, + terms: sortTerms(action.payload.terms), getTermsFailure: false, loadingTermsForChallengeId: '', }; @@ -210,11 +221,12 @@ function onCheckStatusDone(state, action) { checkingStatus: false, checkStatusError: false, canRegister, - terms: action.payload, + terms: sortTerms(action.payload), selectedTerm, }; } + /** * Creates a new Terms reducer with the specified initial state. * @param {Object} initialState Initial state. @@ -282,7 +294,7 @@ function create(initialState) { */ export function factory(req) { if (req && req.url.match(/^\/challenges\/\d+/)) { - const tokenV2 = req.cookies.tcjwt; + const tokenV2 = getAuthTokens(req).tokenV2; const challengeId = req.url.match(/\d+/)[0]; return toFSA(actions.terms.getTermsDone(challengeId, tokenV2)).then((result) => { const state = onGetTermsDone({}, result); diff --git a/src/shared/services/__mocks__/data/terms-auth.json b/src/shared/services/__mocks__/data/terms-auth.json index 3abd00de83..082acaa5ea 100644 --- a/src/shared/services/__mocks__/data/terms-auth.json +++ b/src/shared/services/__mocks__/data/terms-auth.json @@ -13,7 +13,7 @@ "title": "Standard Terms for TopCoder Competitions v2.1", "url": "", "agreeabilityType": "Electronically-agreeable", - "agreed": false, + "agreed": true, "templateId": null }, { diff --git a/src/shared/services/groups.js b/src/shared/services/groups.js index 8ca4a5bc45..71251fc6de 100644 --- a/src/shared/services/groups.js +++ b/src/shared/services/groups.js @@ -4,6 +4,19 @@ import { getApiV3 } from './api'; +/** + * Handles given response from the groups API. + * @param {Object} response + * @return {Promise} On success resolves to the data fetched from the API. + */ +function handleApiResponse(response) { + if (!response.ok) throw new Error(response.statusText); + return response.json().then(({ result }) => { + if (result.status !== 200) throw new Error(result.content); + return result.content; + }); +} + class GroupService { /** * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. @@ -25,13 +38,22 @@ class GroupService { addMember(groupId, memberId, membershipType) { return this.private.api.postJson(`/groups/${groupId}/members`, { param: { memberId, membershipType }, - }).then((res) => { - if (!res.ok) throw new Error(res.statusText); - return res.json(); - }).then((res) => { - if (res.result.status !== 200) throw new Error(res.result.content); - return res.result.content; - }); + }).then(handleApiResponse); + } + + /** + * Gets detailed information about the group. + * @param {String} groupId + * @param {Boolean} withSubGroups Optional. Defaults to true. Specifies, + * whether the response should information about sub-groups, if any. + * @return {Promise} On success resolves to the group data object. + */ + get(groupId, withSubGroups = true) { + let url = `/groups/${groupId}`; + if (withSubGroups) { + url = `${url}/getSubGroups?includeSubGroups=true&oneLevel=false`; + } + return this.private.api.get(url).then(handleApiResponse); } /** @@ -42,12 +64,7 @@ class GroupService { */ getMembers(groupId) { return this.private.api.get(`/groups/${groupId}/members`) - .then(res => (res.ok ? res.json() : new Error(res.statusText))) - .then(res => ( - res.result.status === 200 - ? res.result.content - : new Error(res.result.content) - )); + .then(handleApiResponse); } } diff --git a/src/shared/utils/tc.js b/src/shared/utils/tc.js index 4cf79dee75..fcf4cc390b 100644 --- a/src/shared/utils/tc.js +++ b/src/shared/utils/tc.js @@ -3,8 +3,9 @@ */ import _ from 'lodash'; +import config from 'utils/config'; import moment from 'moment-timezone'; -import config from './config'; +import { isTokenExpired } from 'tc-accounts'; /** * Codes of the Topcoder communities. @@ -26,6 +27,43 @@ export const USER_ROLES = { SUBMITTER: 'Submitter', }; +/* This is the internal implementation of addGroup(..) function. + * It does exactly what is described there, but mutates its "groups" argument. + */ +function addGroupPrivate(groups, srcGroup) { + const group = _.clone(srcGroup); + if (group.subGroups) { + if (group.subGroups.length) { + group.subGroupIds = group.subGroups.map(g => g.id); + group.subGroups.forEach(g => addGroupPrivate(groups, g)); + } + delete group.subGroups; + } + groups[group.id] = group; // eslint-disable-line no-param-reassign + return groups; +} + +/** + * This function merges "srcGroup" into "groups" (without mutation of original + * objects) and returns the result. + * @param {Object} groups Map of known user groups, where: + * - Group IDs are the keys; + * - Group data object are the values; + * - In each group data object the "subGroups" field (if it was present), + * is replaced by "subGroupIds" array that holds only IDs of the immediate + * child groups. + * @param {Object} srcGroup User group data object, as returned from the API; + * i.e. it may contain the "subGroups" field, which is an array of child group + * data objects, and thus it may represent a tree of related user groups. + * @return {Object} Resulting group map, that contains all original groups from + * "groups", plus all groups from the "srcGroup" tree. If "srcGroup" contains + * any groups already present in "groups" the data from "srcGroup" will + * overwrite corresponding data from "groups". + */ +export function addGroup(groups, srcGroup) { + return addGroupPrivate(_.clone(groups), srcGroup); +} + /** * Given a rating value, returns corresponding color. * @param {Number} rating Rating. @@ -85,7 +123,7 @@ export function getCommunitiesMetadata(communityId) { * from the api (it looks like reducer should be improved, but it * is easier just to set these defaults). */ const metadata = _.defaults(JSON.parse(data), { - authorizedGorupIds: null, + authorizedGroupIds: null, challengeFilter: null, challengeListing: null, communityId: '', @@ -110,6 +148,84 @@ export function getCommunitiesMetadata(communityId) { return null; } +/** + * Given ExpressJS HTTP request it extracts Topcoder auth tokens from cookies, + * if they are present there and are not expired. + * @param {Object} req ExpressJS HTTP request. For convenience, it is allowed to + * call this function without "req" argument (will result in empty tokens). + * @return {Object} It will contain two string fields: tokenV2 and tokenV3. + * These strings will be empty if corresponding cookies are absent, or expired. + */ +export function getAuthTokens(req = {}) { + const cookies = req.cookies || {}; + let tokenV2 = cookies.tcjwt; + let tokenV3 = cookies.v3jwt; + if (!tokenV2 || isTokenExpired(tokenV2, config.AUTH_DROP_TIME)) tokenV2 = ''; + if (!tokenV3 || isTokenExpired(tokenV3, config.AUTH_DROP_TIME)) tokenV3 = ''; + return { tokenV2, tokenV3 }; +} + +/** + * Tests whether the user belongs to the specified group(s) or their descendant + * groups. + * + * The following pattern of use is assumed: + * + * 1. You load user's profile ("groups" field of the profile should be passed + * into "userGroups" argument); + * + * 2. You ensure that you have loaded detailed group information for each group + * you are going to test against (you pass this information into "apiGroups" + * argument; it should include data about all descendant groups of the groups + * you gonna test against; and once you have loaded necessary data from the + * API you can reuse them for multiple "isGroupMember" calls). + * + * 3. Finally, you call "isGroupMember", passing as "groupId" argument the ID + * of the group to test (or the array of group IDs). This function will do + * its best to make the check in the most efficient way. + * + * @param {String|String[]} groupId ID, or an array of IDs, of the groups to + * test against. + * @param {Object[]} userGroups Array of groups the user belongs to. This is + * the array we store under "auth.profile.groups" path of Redux state once + * the user is authenticated and his profile is loaded. + * @param {Object{}} apiGroups Group detailes fetched from the API. This is + * the object from "groups.groups" path of Redux state. + * @return {Boolean} "true" if the user belongs to some of the specified groups + * or their descendant groups; "false" otherwise. + */ +export function isGroupMember(groupId, userGroups, apiGroups) { + const queue = _.isArray(groupId) ? groupId : [groupId]; + if (!queue.length) return true; + if (!userGroups.length) return false; + + /* Algorithmically, the group(s) we are testing against are a tree, or muliple + * trees of groups; "groupId" specifies their root(s) and "apiGroups" gives + * the structure. We want to find out, whether any of the nodes in the trees + * specified in such way is listed in the array of user groups. Basically, + * we do a breadth-first search through the tree. + * Just in case, we check that we don't check the same group multiple times, + * so if at some point we allow in the API to include the same group into + * multiple parent groups, this code will still work. */ + const userGroupIds = new Set(); + const testedGroupIds = new Set(); + userGroups.forEach(g => userGroupIds.add(g.id)); + let queuePosition = 0; + while (queuePosition < queue.length) { + const id = queue[queuePosition]; + if (userGroupIds.has(id)) return true; + testedGroupIds.add(id); + const g = apiGroups[id]; + if (g && g.subGroupIds) { + g.subGroupIds.forEach((sgId) => { + if (!testedGroupIds.has(sgId)) queue.push(sgId); + }); + } + queuePosition += 1; + } + return false; +} + /** * Calculate the difference from now to a specified date * adopt from topcoder-app repo