diff --git a/__tests__/shared/utils/challenge-listing/constants.test.js b/__tests__/shared/utils/challenge-listing/constants.test.js
new file mode 100644
index 0000000000..ff07692216
--- /dev/null
+++ b/__tests__/shared/utils/challenge-listing/constants.test.js
@@ -0,0 +1,38 @@
+import {
+ getVisibleChallengeTypes,
+ sanitizeChallengeTypeFilter,
+} from 'utils/challenge-listing/constants';
+
+describe('challenge listing constants', () => {
+ test('returns only the supported challenge types once and in filter order', () => {
+ const challengeTypes = [
+ { name: 'AI', abbreviation: 'AI' },
+ { name: 'AI Engineering', abbreviation: 'AIENG' },
+ { name: 'Marathon Match', abbreviation: 'MM' },
+ { name: 'Challenge', abbreviation: 'CH' },
+ { name: 'Marathon Match', abbreviation: 'MM' },
+ { name: 'Task', abbreviation: 'TSK' },
+ { name: 'First2Finish', abbreviation: 'F2F' },
+ { name: 'type-1778748614529', abbreviation: 'type-1778748614529' },
+ ];
+
+ expect(getVisibleChallengeTypes(challengeTypes)).toEqual([
+ { name: 'Challenge', abbreviation: 'CH' },
+ { name: 'First2Finish', abbreviation: 'F2F' },
+ { name: 'Marathon Match', abbreviation: 'MM' },
+ { name: 'Task', abbreviation: 'TSK' },
+ ]);
+ });
+
+ test('removes hidden and duplicated selected challenge type filters', () => {
+ expect(sanitizeChallengeTypeFilter([
+ 'AI',
+ 'CH',
+ 'F2F',
+ 'MM',
+ 'MM',
+ 'TSK',
+ 'type-1778748614529',
+ ])).toEqual(['CH', 'F2F', 'MM', 'TSK']);
+ });
+});
diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js
index e456ab9183..cd82a0c483 100644
--- a/__tests__/shared/utils/mm-review-summations.test.js
+++ b/__tests__/shared/utils/mm-review-summations.test.js
@@ -1,5 +1,8 @@
/* eslint-env jest */
-import { buildMmSubmissionData } from '../../../src/shared/utils/mm-review-summations';
+import {
+ buildMmSubmissionData,
+ buildStatisticsData,
+} from '../../../src/shared/utils/mm-review-summations';
describe('buildMmSubmissionData', () => {
it('keeps newer raw submissions that do not have review summations yet', () => {
@@ -280,3 +283,112 @@ describe('buildMmSubmissionData', () => {
]);
});
});
+
+describe('buildStatisticsData', () => {
+ it('omits processing and failed scorer updates from dashboard graph data', () => {
+ const reviewSummations = [
+ {
+ aggregateScore: 0,
+ id: 'summation-processing-zero',
+ isProvisional: true,
+ metadata: {
+ testStatus: 'IN PROGRESS',
+ testType: 'provisional',
+ },
+ reviewedDate: '2026-05-29T01:00:00.000Z',
+ submissionId: 'submission-processing',
+ submitterHandle: 'alpha',
+ submitterId: '1001',
+ },
+ {
+ aggregateScore: -1,
+ id: 'summation-failed',
+ isProvisional: true,
+ metadata: {
+ testStatus: 'FAILED',
+ testType: 'provisional',
+ },
+ reviewedDate: '2026-05-29T01:05:00.000Z',
+ submissionId: 'submission-failed',
+ submitterHandle: 'alpha',
+ submitterId: '1001',
+ },
+ {
+ aggregateScore: 0,
+ id: 'summation-success-zero',
+ isProvisional: true,
+ metadata: {
+ testStatus: 'SUCCESS',
+ testType: 'provisional',
+ },
+ reviewedDate: '2026-05-29T01:10:00.000Z',
+ submissionId: 'submission-success-zero',
+ submitterHandle: 'beta',
+ submitterId: '1002',
+ },
+ {
+ aggregateScore: 84.25,
+ id: 'summation-success',
+ isProvisional: true,
+ metadata: {
+ testStatus: 'SUCCESS',
+ testType: 'provisional',
+ },
+ reviewedDate: '2026-05-29T01:15:00.000Z',
+ submissionId: 'submission-success',
+ submitterHandle: 'alpha',
+ submitterId: '1001',
+ },
+ ];
+
+ const result = buildStatisticsData(reviewSummations);
+
+ expect(result).toHaveLength(2);
+ expect(result).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ handle: 'alpha',
+ submissions: [
+ expect.objectContaining({
+ score: 84.25,
+ submissionId: 'submission-success',
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ handle: 'beta',
+ submissions: [
+ expect.objectContaining({
+ score: 0,
+ submissionId: 'submission-success-zero',
+ }),
+ ],
+ }),
+ ]));
+ });
+
+ it('keeps legacy non-negative summations when scorer status metadata is absent', () => {
+ const result = buildStatisticsData([
+ {
+ aggregateScore: 72.5,
+ id: 'summation-legacy',
+ isProvisional: true,
+ reviewedDate: '2026-05-29T02:00:00.000Z',
+ submissionId: 'submission-legacy',
+ submitterHandle: 'gamma',
+ submitterId: '1003',
+ },
+ ]);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ handle: 'gamma',
+ submissions: [
+ expect.objectContaining({
+ score: 72.5,
+ submissionId: 'submission-legacy',
+ }),
+ ],
+ }),
+ ]);
+ });
+});
diff --git a/__tests__/shared/utils/terms.test.js b/__tests__/shared/utils/terms.test.js
new file mode 100644
index 0000000000..ca7167f140
--- /dev/null
+++ b/__tests__/shared/utils/terms.test.js
@@ -0,0 +1,35 @@
+import {
+ getDocuSignTemplateIdForTerm,
+ isNdaTerm,
+} from 'utils/terms';
+
+describe('terms utils', () => {
+ const NEW_NDA_TEMPLATE_ID = '400b989d-1c75-4889-b6f6-421e1f924709';
+
+ test('detects NDA terms by title', () => {
+ expect(isNdaTerm({ title: 'Appirio NDA v2.0' })).toBe(true);
+ expect(isNdaTerm({ title: 'Competition Non-Disclosure Agreement' })).toBe(true);
+ expect(isNdaTerm({ title: 'Assignment Terms' })).toBe(false);
+ });
+
+ test('uses configured DocuSign template for NDA terms', () => {
+ expect(getDocuSignTemplateIdForTerm({
+ docusignTemplateId: 'old-template-id',
+ title: 'Appirio NDA v2.0',
+ })).toBe(NEW_NDA_TEMPLATE_ID);
+ });
+
+ test('keeps terms-service template for non-NDA terms', () => {
+ expect(getDocuSignTemplateIdForTerm({
+ docusignTemplateId: 'assignment-template-id',
+ title: 'Assignment Terms',
+ })).toBe('assignment-template-id');
+ });
+
+ test('handles missing terms details', () => {
+ expect(isNdaTerm(null)).toBe(false);
+ expect(isNdaTerm()).toBe(false);
+ expect(getDocuSignTemplateIdForTerm(null)).toBe(undefined);
+ expect(getDocuSignTemplateIdForTerm()).toBe(undefined);
+ });
+});
diff --git a/config/backup-default.js b/config/backup-default.js
index 4bacfa2b87..40c716e382 100644
--- a/config/backup-default.js
+++ b/config/backup-default.js
@@ -70,6 +70,12 @@ module.exports = {
* agreement flow. */
MOCK_TERMS_SERVICE: false,
+ /* Optional DocuSign template override for NDA-like terms. When set, the
+ * terms modal requests this template for terms whose title includes NDA or
+ * Non-Disclosure, even if the Terms API still returns an older template id.
+ */
+ NDA_DOCUSIGN_TEMPLATE_ID: '400b989d-1c75-4889-b6f6-421e1f924709',
+
/* Holds params to signup for different newsletters. */
NEWSLETTER_SIGNUP: {
DEFAUL_LIST_ID: '28bfd3c062',
diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js
index 88dd2a9a04..a6dac4008d 100644
--- a/config/custom-environment-variables.js
+++ b/config/custom-environment-variables.js
@@ -12,6 +12,7 @@ module.exports = {
DISABLE_SERVICE_WORKER: 'DISABLE_SERVICE_WORKER',
LOG_ENTRIES_TOKEN: 'LOG_ENTRIES_TOKEN',
MOCK_TERMS_SERVICE: 'MOCK_TERMS_SERVICE',
+ NDA_DOCUSIGN_TEMPLATE_ID: 'NDA_DOCUSIGN_TEMPLATE_ID',
NEWSLETTER_SIGNUP: {
COGNITIVE: {
diff --git a/config/default.js b/config/default.js
index ef3558fdae..c8f23a3e43 100644
--- a/config/default.js
+++ b/config/default.js
@@ -72,6 +72,12 @@ module.exports = {
* agreement flow. */
MOCK_TERMS_SERVICE: false,
+ /* Optional DocuSign template override for NDA-like terms. When set, the
+ * terms modal requests this template for terms whose title includes NDA or
+ * Non-Disclosure, even if the Terms API still returns an older template id.
+ */
+ NDA_DOCUSIGN_TEMPLATE_ID: '400b989d-1c75-4889-b6f6-421e1f924709',
+
/* Holds params to signup for different newsletters. */
NEWSLETTER_SIGNUP: {
DEFAUL_LIST_ID: '28bfd3c062',
diff --git a/config/production.js b/config/production.js
index 40d72858af..f805153f72 100644
--- a/config/production.js
+++ b/config/production.js
@@ -18,6 +18,7 @@ module.exports = {
SECURE: true,
},
LOG_ENTRIES_TOKEN: '',
+ NDA_DOCUSIGN_TEMPLATE_ID: '8b101e82-87c0-42c9-8440-d922749c4076',
SERVER_API_KEY: 'aa9ccf36-3936-450c-9983-097ddba51bef',
GOOGLE_ANALYTICS_ID: 'UA-6340959-1',
URL: {
diff --git a/config/qa.js b/config/qa.js
index 7ac7b5291b..9625992fcd 100644
--- a/config/qa.js
+++ b/config/qa.js
@@ -1,5 +1,6 @@
module.exports = {
SEGMENT_IO_API_KEY: 'QBtLgV8vCiuRX1lDikbMjcoe9aCHkF6n',
+ NDA_DOCUSIGN_TEMPLATE_ID: '',
SERVER_API_KEY: '79b2d5eb-c1fd-42c4-9391-6b2c9780d591',
API: {
ENGAGEMENTS: 'https://api.topcoder-qa.com/v6/engagements/engagements',
diff --git a/src/shared/components/Terms/TermDetails.jsx b/src/shared/components/Terms/TermDetails.jsx
index b0ec297f8b..d220fd05e3 100644
--- a/src/shared/components/Terms/TermDetails.jsx
+++ b/src/shared/components/Terms/TermDetails.jsx
@@ -6,6 +6,7 @@
import React from 'react';
import PT from 'prop-types';
import LoadingIndicator from 'components/LoadingIndicator';
+import { getDocuSignTemplateIdForTerm } from 'utils/terms';
import './TermDetails.scss';
@@ -20,8 +21,9 @@ export default class TermDetails extends React.Component {
componentWillMount() {
const { details, getDocuSignUrl } = this.props;
- if (details.agreeabilityType !== 'Electronically-agreeable' && details.docusignTemplateId) {
- getDocuSignUrl(details.docusignTemplateId);
+ const docusignTemplateId = getDocuSignTemplateIdForTerm(details);
+ if (docusignTemplateId) {
+ getDocuSignUrl(docusignTemplateId);
this.setState({ loadingFrame: true });
}
}
@@ -38,11 +40,14 @@ export default class TermDetails extends React.Component {
loadingDocuSignUrl,
} = this.props;
const { loadingFrame } = this.state;
+ const docusignTemplateId = getDocuSignTemplateIdForTerm(details);
+ const isDocuSignTerm = Boolean(docusignTemplateId);
return (
{
details.agreeabilityType === 'Electronically-agreeable'
+ && !isDocuSignTerm
&& (
}
{
- details.agreeabilityType !== 'Electronically-agreeable' && details.docusignTemplateId
- && !loadingDocuSignUrl && docuSignUrl
+ isDocuSignTerm && !loadingDocuSignUrl && docuSignUrl
&& (
{
diff --git a/src/shared/components/Terms/index.jsx b/src/shared/components/Terms/index.jsx
index 49ad395401..a77f2249c3 100644
--- a/src/shared/components/Terms/index.jsx
+++ b/src/shared/components/Terms/index.jsx
@@ -11,6 +11,7 @@ import PT from 'prop-types';
import { Modal, PrimaryButton, Button } from 'topcoder-react-ui-kit';
import LoadingIndicator from 'components/LoadingIndicator';
import FocusTrap from 'focus-trap-react';
+import { getDocuSignTemplateIdForTerm } from 'utils/terms';
import TermDetails from './TermDetails';
import style from './styles.scss';
@@ -139,6 +140,7 @@ export default class Terms extends React.Component {
loadingDocuSignUrl, selectedTerm, viewOnly, checkingStatus,
description, defaultTitle,
} = this.props;
+ const isDocuSignTerm = Boolean(getDocuSignTemplateIdForTerm(details));
const handleHorizonalScroll = (e) => {
const scrollElement = e.target;
@@ -283,7 +285,8 @@ export default class Terms extends React.Component {
!isLoadingTerms && !checkingStatus && selectedTerm && details
&& !viewOnly
&& loadingTermId !== _.toString(selectedTerm.id)
- && details.agreeabilityType === 'Electronically-agreeable' ? (
+ && details.agreeabilityType === 'Electronically-agreeable'
+ && !isDocuSignTerm ? (
{
selectedTerm.agreed
diff --git a/src/shared/components/challenge-detail/Specification/SpecificationComponent/styles.scss b/src/shared/components/challenge-detail/Specification/SpecificationComponent/styles.scss
index a1be329593..273a89b966 100644
--- a/src/shared/components/challenge-detail/Specification/SpecificationComponent/styles.scss
+++ b/src/shared/components/challenge-detail/Specification/SpecificationComponent/styles.scss
@@ -2,6 +2,11 @@
.container {
:global {
+ ul,
+ ol {
+ padding-left: 20px;
+ }
+
table {
display: block;
width: 100%;
diff --git a/src/shared/containers/challenge-listing/FilterPanel.jsx b/src/shared/containers/challenge-listing/FilterPanel.jsx
index 367e70f732..b6b3e2103b 100644
--- a/src/shared/containers/challenge-listing/FilterPanel.jsx
+++ b/src/shared/containers/challenge-listing/FilterPanel.jsx
@@ -17,7 +17,10 @@ import { connect } from 'react-redux';
import qs from 'qs';
import _ from 'lodash';
import { createStaticRanges } from 'utils/challenge-listing/date-range';
-import { EXCLUDED_CHALLENGE_TYPE_NAMES } from 'utils/challenge-listing/constants';
+import {
+ getVisibleChallengeTypes,
+ sanitizeChallengeTypeFilter,
+} from 'utils/challenge-listing/constants';
const MIN = 60 * 1000;
@@ -74,7 +77,9 @@ export class Container extends React.Component {
query.customDate = customDate;
}
- if (query.types && query.types.length) {
+ if (query.types && sanitizeChallengeTypeFilter(
+ Array.isArray(query.types) ? query.types : [query.types],
+ ).length) {
this.initialDefaultChallengeTypes = true;
}
@@ -105,8 +110,7 @@ export class Container extends React.Component {
});
this.initialDefaultChallengeTypes = true;
} else if (validTypes.length && currentTypes.length) {
- const validAbbreviations = validTypes.map(item => item.abbreviation);
- const sanitizedTypes = currentTypes.filter(type => validAbbreviations.includes(type));
+ const sanitizedTypes = sanitizeChallengeTypeFilter(currentTypes);
if (sanitizedTypes.length !== currentTypes.length) {
if (!sanitizedTypes.length) {
this.initialDefaultChallengeTypes = false;
@@ -235,16 +239,11 @@ function mapDispatchToProps(dispatch) {
function mapStateToProps(state, ownProps) {
const cl = state.challengeListing;
const tc = state.tcCommunities;
- const filteredChallengeTypes = cl.challengeTypes
- .filter(type => !EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name));
- const excludedTypeAbbreviations = cl.challengeTypes
- .filter(type => EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name))
- .map(type => type.abbreviation);
+ const filteredChallengeTypes = getVisibleChallengeTypes(cl.challengeTypes);
let filterState = cl.filter;
const existingTypes = Array.isArray(cl.filter.types) ? cl.filter.types : [];
- if (excludedTypeAbbreviations.length && existingTypes.length) {
- const sanitizedTypes = existingTypes
- .filter(type => !excludedTypeAbbreviations.includes(type));
+ if (existingTypes.length) {
+ const sanitizedTypes = sanitizeChallengeTypeFilter(existingTypes);
if (sanitizedTypes.length !== existingTypes.length) {
filterState = {
...cl.filter,
diff --git a/src/shared/containers/terms-detail/index.jsx b/src/shared/containers/terms-detail/index.jsx
index 66755806ec..9aa78f4dae 100644
--- a/src/shared/containers/terms-detail/index.jsx
+++ b/src/shared/containers/terms-detail/index.jsx
@@ -15,6 +15,7 @@ import MetaTags from 'components/MetaTags';
import { Modal, PrimaryButton } from 'topcoder-react-ui-kit';
import SwitchWithLabel from 'components/SwitchWithLabel';
import { themr } from 'react-css-super-themr';
+import { getDocuSignTemplateIdForTerm } from 'utils/terms';
import styles from './styles.scss';
const ACCEPTANCE_LABEL = 'I understand and agree';
@@ -87,6 +88,7 @@ class TermsDetailPageContainer extends React.Component {
theme,
} = this.props;
const { termsAccepted, showModal } = this.state;
+ const isDocuSignTerm = Boolean(getDocuSignTemplateIdForTerm(details));
return (
@@ -131,7 +133,7 @@ class TermsDetailPageContainer extends React.Component {
{
agreeingTerm !== termId && details && !details.agreed
&& agreeTermFailure === undefined
- && details.agreeabilityType !== 'DocuSignable'
+ && !isDocuSignTerm
? (
EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name))
- .map(type => type.abbreviation);
- if (excludedTypeAbbreviations.length && Array.isArray(basePayload.types)) {
- sanitizedPayload.types = basePayload.types
- .filter(type => !excludedTypeAbbreviations.includes(type));
+ if (Array.isArray(basePayload.types)) {
+ sanitizedPayload.types = sanitizeChallengeTypeFilter(basePayload.types);
}
const filter = _.pickBy(_.pick(
sanitizedPayload,
diff --git a/src/shared/utils/challenge-listing/constants.js b/src/shared/utils/challenge-listing/constants.js
index fbddb9fdca..520cb09147 100644
--- a/src/shared/utils/challenge-listing/constants.js
+++ b/src/shared/utils/challenge-listing/constants.js
@@ -1,4 +1,71 @@
-const EXCLUDED_CHALLENGE_TYPE_NAMES = ['Topgear Task'];
+const VISIBLE_CHALLENGE_TYPES = [
+ {
+ name: 'Challenge',
+ abbreviation: 'CH',
+ },
+ {
+ name: 'First2Finish',
+ abbreviation: 'F2F',
+ },
+ {
+ name: 'Marathon Match',
+ abbreviation: 'MM',
+ },
+ {
+ name: 'Task',
+ abbreviation: 'TSK',
+ },
+];
-export { EXCLUDED_CHALLENGE_TYPE_NAMES };
-export default EXCLUDED_CHALLENGE_TYPE_NAMES;
+const VISIBLE_CHALLENGE_TYPE_ABBREVIATIONS = VISIBLE_CHALLENGE_TYPES
+ .map(type => type.abbreviation);
+
+/**
+ * Returns the challenge types that should be displayed in listing filters.
+ *
+ * @param {Array