From 33dcb6f8aa0717fb18e335c2ced72cf16af7306c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 19 Jun 2026 09:30:30 +1000 Subject: [PATCH 1/4] Updates for PM-5330 --- .../shared/utils/mm-review-summations.test.js | 114 +++++++++++++++++- __tests__/shared/utils/terms.test.js | 28 +++++ config/backup-default.js | 6 + config/custom-environment-variables.js | 1 + config/default.js | 6 + config/production.js | 1 + config/qa.js | 1 + src/shared/components/Terms/TermDetails.jsx | 16 ++- src/shared/components/Terms/index.jsx | 5 +- src/shared/containers/terms-detail/index.jsx | 4 +- src/shared/utils/mm-review-summations.js | 89 ++++++++++++-- src/shared/utils/terms.js | 29 +++++ 12 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 __tests__/shared/utils/terms.test.js create mode 100644 src/shared/utils/terms.js diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js index 0edddc3731..112fe37dc7 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', () => { @@ -253,3 +256,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..5363d624be --- /dev/null +++ b/__tests__/shared/utils/terms.test.js @@ -0,0 +1,28 @@ +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'); + }); +}); 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/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 ? (
= 0; +} + function toTimestampValue(value) { if (!value) { return 0; @@ -815,35 +865,48 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] }); } +/** + * Builds Marathon Match dashboard graph data from Review API summations. + * Processing and failed scorer updates are omitted until a successful completed + * summation is available for the submission. + * + * @param {Array} reviewSummations Review summations returned by Review API. + * @returns {Array} Member-grouped submission score points for the dashboard. + */ export function buildStatisticsData(reviewSummations = []) { if (!Array.isArray(reviewSummations) || !reviewSummations.length) { return []; } - const includeOnlyProvisional = reviewSummations.some((summation) => { + const graphableSummations = []; + reviewSummations.forEach((summation, index) => { if (!summation) { - return false; + return; } const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); if (_.isNil(score)) { - return false; + return; + } + if (!hasCompletedSuccessfulScoring(summation, score)) { + return; } - return getSummationScoreClassification(summation).isProvisional; + + graphableSummations.push({ index, score, summation }); }); - const grouped = new Map(); + if (!graphableSummations.length) { + return []; + } - reviewSummations.forEach((summation, index) => { - if (!summation) { - return; - } + const includeOnlyProvisional = graphableSummations.some( + ({ summation }) => getSummationScoreClassification(summation).isProvisional, + ); + + const grouped = new Map(); + graphableSummations.forEach(({ index, score, summation }) => { const timestamp = getSummationTimestamp(summation); const timestampValue = toTimestampValue(timestamp); - const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); - if (_.isNil(score)) { - return; - } const scoreType = getSummationScoreClassification(summation); if (includeOnlyProvisional) { if (!scoreType.isProvisional) { diff --git a/src/shared/utils/terms.js b/src/shared/utils/terms.js new file mode 100644 index 0000000000..6841d1010a --- /dev/null +++ b/src/shared/utils/terms.js @@ -0,0 +1,29 @@ +import { config } from 'topcoder-react-utils'; + +const NDA_TITLE_PATTERN = /\bnda\b|non[-\s]?disclosure/i; + +/** + * Checks whether a terms record represents an NDA-style agreement. + * + * @param {Object} term terms-service record or details payload. + * @returns {Boolean} true when the term title is NDA/non-disclosure related. + */ +export function isNdaTerm(term = {}) { + return NDA_TITLE_PATTERN.test(term.title || ''); +} + +/** + * Resolves the DocuSign template id to use for a terms-service record. + * + * @param {Object} term terms-service record or details payload. + * @returns {String|Number|undefined} configured NDA template id for NDA terms, + * or the template id returned by terms-service for all other terms. + */ +export function getDocuSignTemplateIdForTerm(term = {}) { + const configuredNdaTemplateId = config.NDA_DOCUSIGN_TEMPLATE_ID; + if (configuredNdaTemplateId && isNdaTerm(term)) { + return configuredNdaTemplateId; + } + + return term.docusignTemplateId; +} From f44e6ee346b67e8b8f0ac890c7d145042d302a0d Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 19 Jun 2026 10:01:21 +1000 Subject: [PATCH 2/4] PM-5393: Restrict challenge type filters What was broken The community challenge listing Type filter displayed challenge types that should not be selectable, including AI, AI Engineering, test/generated types, and duplicate Marathon Match entries. Root cause The filter panel used the raw challenge type list returned by the API, only excluding Topgear Task by name. New or duplicated API challenge type records were therefore rendered directly and persisted in the selected type query state. What was changed Added a visible challenge type whitelist for Challenge, First2Finish, Marathon Match, and Task. The listing filter panel now derives its visible type list from that whitelist, preserves the expected order, removes duplicate type records, and sanitizes selected type values before they reach filter state or query handling. Any added/updated tests Added coverage for visible challenge type derivation and selected type sanitization, including AI, AI Engineering, generated type values, and duplicate Marathon Match values. --- .../utils/challenge-listing/constants.test.js | 38 ++++++++++ .../challenge-listing/FilterPanel.jsx | 23 +++--- .../reducers/challenge-listing/index.js | 10 +-- .../utils/challenge-listing/constants.js | 73 ++++++++++++++++++- 4 files changed, 122 insertions(+), 22 deletions(-) create mode 100644 __tests__/shared/utils/challenge-listing/constants.test.js 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/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/reducers/challenge-listing/index.js b/src/shared/reducers/challenge-listing/index.js index a35a06d046..3f7ae44ed4 100644 --- a/src/shared/reducers/challenge-listing/index.js +++ b/src/shared/reducers/challenge-listing/index.js @@ -15,7 +15,7 @@ import { actions as actionsUtils, } from 'topcoder-react-lib'; import { REVIEW_OPPORTUNITY_TYPES } from 'utils/tc'; -import { EXCLUDED_CHALLENGE_TYPE_NAMES } from 'utils/challenge-listing/constants'; +import { sanitizeChallengeTypeFilter } from 'utils/challenge-listing/constants'; import filterPanel from './filter-panel'; import sidebar, { factory as sidebarFactory } from './sidebar'; @@ -408,12 +408,8 @@ function onSetFilter(state, { payload }) { * do it very carefuly (many params are not validated). */ const basePayload = _.isPlainObject(payload) ? payload : {}; const sanitizedPayload = { ...basePayload }; - const excludedTypeAbbreviations = state.challengeTypes - .filter(type => 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} challengeTypes Challenge type records loaded from the + * challenge API. + * @return {Array} One API challenge type record per visible type, in + * the order used by the filter panel. + */ +function getVisibleChallengeTypes(challengeTypes = []) { + if (!Array.isArray(challengeTypes)) { + return []; + } + + return VISIBLE_CHALLENGE_TYPES + .map(visibleType => ( + challengeTypes.find(type => ( + type.name === visibleType.name + && type.abbreviation === visibleType.abbreviation + )) + || challengeTypes.find(type => type.name === visibleType.name) + )) + .filter(Boolean); +} + +/** + * Removes hidden and duplicated challenge type filter values. + * + * @param {Array} types Selected challenge type abbreviations. + * @return {Array} Selected abbreviations limited to the visible filter + * types. + */ +function sanitizeChallengeTypeFilter(types = []) { + if (!Array.isArray(types)) { + return types; + } + + return types.filter((type, index) => ( + VISIBLE_CHALLENGE_TYPE_ABBREVIATIONS.includes(type) + && types.indexOf(type) === index + )); +} + +export { + VISIBLE_CHALLENGE_TYPES, + VISIBLE_CHALLENGE_TYPE_ABBREVIATIONS, + getVisibleChallengeTypes, + sanitizeChallengeTypeFilter, +}; +export default VISIBLE_CHALLENGE_TYPES; From c82e58d0f519b7049b2aead5476331791efb44d5 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 22 Jun 2026 10:29:45 +1000 Subject: [PATCH 3/4] Fix for terms issue noted in dev --- __tests__/shared/utils/terms.test.js | 7 +++++++ src/shared/utils/terms.js | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/__tests__/shared/utils/terms.test.js b/__tests__/shared/utils/terms.test.js index 5363d624be..ca7167f140 100644 --- a/__tests__/shared/utils/terms.test.js +++ b/__tests__/shared/utils/terms.test.js @@ -25,4 +25,11 @@ describe('terms utils', () => { 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/src/shared/utils/terms.js b/src/shared/utils/terms.js index 6841d1010a..192e6d6ff0 100644 --- a/src/shared/utils/terms.js +++ b/src/shared/utils/terms.js @@ -5,17 +5,17 @@ const NDA_TITLE_PATTERN = /\bnda\b|non[-\s]?disclosure/i; /** * Checks whether a terms record represents an NDA-style agreement. * - * @param {Object} term terms-service record or details payload. + * @param {Object|null} term terms-service record or details payload. * @returns {Boolean} true when the term title is NDA/non-disclosure related. */ export function isNdaTerm(term = {}) { - return NDA_TITLE_PATTERN.test(term.title || ''); + return NDA_TITLE_PATTERN.test((term && term.title) || ''); } /** * Resolves the DocuSign template id to use for a terms-service record. * - * @param {Object} term terms-service record or details payload. + * @param {Object|null} term terms-service record or details payload. * @returns {String|Number|undefined} configured NDA template id for NDA terms, * or the template id returned by terms-service for all other terms. */ @@ -25,5 +25,5 @@ export function getDocuSignTemplateIdForTerm(term = {}) { return configuredNdaTemplateId; } - return term.docusignTemplateId; + return term ? term.docusignTemplateId : undefined; } From 5a17deb58600b04ae35973c1615738939e152645 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 22 Jun 2026 14:12:24 +1000 Subject: [PATCH 4/4] PM-5416: Indent markdown challenge spec lists What was broken Markdown-rendered bullet and numbered lists in challenge specifications sat too close to the left edge, making list content look cramped. Root cause (if identifiable) The markdown specification component only provided custom table styling and did not add list indentation after the app's global/list reset styles. What was changed Added scoped left padding for ordered and unordered lists inside the challenge specification markdown renderer. Any added/updated tests No automated tests were added because this is a visual CSS-only spacing change and the existing Jest setup does not assert compiled SCSS layout. Validated with npm run lint, npm test, and npm run build. --- .../Specification/SpecificationComponent/styles.scss | 5 +++++ 1 file changed, 5 insertions(+) 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%;