diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 9a51b14d5..5270b4962 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -1,9 +1,33 @@ import React from 'react'; import Renderer from 'react-test-renderer/shallow'; +import { errors as mockedErrors } from 'topcoder-react-lib'; import Header from 'components/challenge-detail/Header'; import TabSelector from 'components/challenge-detail/Header/TabSelector'; +jest.mock('topcoder-react-lib', () => ({ + challenge: { + filter: {}, + }, + errors: { + fireErrorMessage: jest.fn(), + }, + services: { + api: {}, + }, + tc: { + CHALLENGE_STATUS: { + ACTIVE: 'ACTIVE', + COMPLETED: 'COMPLETED', + }, + OLD_COMPETITION_TRACKS: {}, + }, +})); + +jest.mock('topcoder-react-ui-kit', () => ({ + PrimaryButton: () => null, +})); + jest.mock('react-responsive', () => ({ useMediaQuery: () => true, })); @@ -21,7 +45,28 @@ function collectText(node) { .reduce((acc, child) => acc.concat(collectText(child)), []); } -function renderHeader(challengeOverrides = {}) { +function findSubmitAction(node) { + if (!React.isValidElement(node)) { + return null; + } + + if ((node.props.to || node.props.onClick) + && collectText(node).includes('Submit a solution')) { + return node; + } + + const children = React.Children.toArray(node.props.children); + for (let index = 0; index < children.length; index += 1) { + const match = findSubmitAction(children[index]); + if (match) { + return match; + } + } + + return null; +} + +function renderHeader(challengeOverrides = {}, propOverrides = {}) { const renderer = new Renderer(); renderer.render(
, ); @@ -89,6 +135,10 @@ function renderHeader(challengeOverrides = {}) { } describe('Challenge detail header actions', () => { + beforeEach(() => { + mockedErrors.fireErrorMessage.mockClear(); + }); + test('hides registration and submission actions for classic task challenges', () => { const output = renderHeader({ type: 'Task', @@ -129,6 +179,53 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).toContain('Register'); expect(collectText(output)).toContain('Submit a solution'); }); + + test('shows the limit-reached message instead of opening the submission page', () => { + const output = renderHeader({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }, { + hasRegistered: true, + mySubmissions: [{ id: 'submission-id' }], + }); + const submitAction = findSubmitAction(output); + + expect(submitAction.props.to).toBeUndefined(); + submitAction.props.onClick(); + + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Submission Limit Reached', + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('keeps the submission page available while slots remain', () => { + const output = renderHeader({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }], + }, { + hasRegistered: true, + mySubmissions: [{ id: 'submission-id' }], + }); + const submitAction = findSubmitAction(output); + + expect(submitAction.props.to).toBe('/challenges/challenge-id/submit'); + expect(submitAction.props.onClick).toBeUndefined(); + expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled(); + }); }); describe('Challenge detail tab counts', () => { diff --git a/__tests__/shared/containers/SubmissionPage.jsx b/__tests__/shared/containers/SubmissionPage.jsx new file mode 100644 index 000000000..9d8541e1b --- /dev/null +++ b/__tests__/shared/containers/SubmissionPage.jsx @@ -0,0 +1,157 @@ +import { errors as mockedErrors } from 'topcoder-react-lib'; +import { getChallengeSubmissions as mockedGetChallengeSubmissions } from 'services/submissions'; + +import { SubmissionsPageContainer } from 'containers/SubmissionPage'; + +jest.mock('topcoder-react-lib', () => ({ + actions: { + challenge: {}, + }, + errors: { + fireErrorMessage: jest.fn(), + }, +})); + +jest.mock('services/submissions', () => ({ + getChallengeSubmissions: jest.fn(), +})); + +jest.mock('actions/page/submission', () => ({ + page: { + submission: {}, + }, +})); +jest.mock('actions/page/challenge-details', () => ({ + page: { + challengeDetails: {}, + }, +})); +jest.mock('actions/tc-communities', () => ({ + tcCommunity: {}, +})); +jest.mock('components/SubmissionPage', () => () => null); +jest.mock('components/tc-communities/AccessDenied', () => ({ + __esModule: true, + CAUSE: { + NOT_AUTHORIZED: 'NOT_AUTHORIZED', + }, + default: () => null, +})); +jest.mock('components/LoadingIndicator', () => () => null); +jest.mock('topcoder-react-ui-kit', () => ({ + PrimaryButton: () => null, +})); + +function createContainerProps(overrides = {}) { + return { + challenge: {}, + challengeId: 'challenge-id', + metadata: [], + submit: jest.fn(), + tokenV2: 'token-v2', + tokenV3: 'token-v3', + track: 'Design', + userId: 'member-id', + ...overrides, + }; +} + +describe('SubmissionsPageContainer submission limits', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('skips the limit lookup for unlimited challenges', async () => { + const props = createContainerProps(); + const container = new SubmissionsPageContainer(props); + const body = {}; + + await container.handleSubmit(body); + + expect(mockedGetChallengeSubmissions).not.toHaveBeenCalled(); + expect(props.submit).toHaveBeenCalledWith( + 'token-v3', + 'token-v2', + 'challenge-id', + body, + 'Design', + ); + }); + + test('submits while a limited challenge still has an available slot', async () => { + mockedGetChallengeSubmissions.mockResolvedValue({ + data: [{ id: 'submission-1' }], + }); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + const body = {}; + + await container.handleSubmit(body); + + expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith( + 'token-v3', + 'challenge-id', + { memberId: 'member-id' }, + ); + expect(props.submit).toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled(); + }); + + test('shows the limit message and does not submit when the limit is reached', async () => { + mockedGetChallengeSubmissions.mockResolvedValue({ + data: [{ id: 'submission-1' }], + }); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + + await container.handleSubmit({}); + + expect(props.submit).not.toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Submission Limit Reached', + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('does not submit when the existing-submission lookup fails', async () => { + mockedGetChallengeSubmissions.mockRejectedValue(new Error('network error')); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + + await container.handleSubmit({}); + + expect(props.submit).not.toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Unable to Verify Submission Limit', + 'We could not verify your existing submissions. Please try again.', + ); + }); +}); diff --git a/__tests__/shared/utils/challenge-detail/submission-limit.test.js b/__tests__/shared/utils/challenge-detail/submission-limit.test.js new file mode 100644 index 000000000..80281bf86 --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/submission-limit.test.js @@ -0,0 +1,75 @@ +/* eslint-env jest */ +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from '../../../../src/shared/utils/challenge-detail/submission-limit'; + +describe('getSubmissionLimit', () => { + test('returns null when submission-limit metadata is missing', () => { + expect(getSubmissionLimit([])).toBeNull(); + }); + + test('returns null for the current unlimited payload', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '', + limit: 'false', + unlimited: 'true', + }), + }])).toBeNull(); + }); + + test('returns the count for the current limited payload', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '3', + limit: 'true', + unlimited: 'false', + }), + }])).toBe(3); + }); + + test('supports legacy numeric values', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: 1, + }])).toBe(1); + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: '2', + }])).toBe(2); + }); + + test('returns null for malformed and invalid counts', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: '{invalid', + }])).toBeNull(); + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '0', + limit: 'true', + unlimited: 'false', + }), + }])).toBeNull(); + }); +}); + +describe('getSubmissionLimitReachedMessage', () => { + test('uses the requested singular limit message', () => { + expect(getSubmissionLimitReachedMessage(1)).toBe( + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('uses a plural message for larger limits', () => { + expect(getSubmissionLimitReachedMessage(3)).toBe( + 'This challenge allows only 3 submissions, and you\'ve already reached that limit.' + + ' To replace one, delete an existing submission first.', + ); + }); +}); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 345ed44c1..9f6786431 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -8,6 +8,7 @@ import _ from 'lodash'; import moment from 'moment'; import 'moment-duration-format'; +import { errors } from 'topcoder-react-lib'; import { isMM, getTrackName, getTypeName } from 'utils/challenge'; import PT from 'prop-types'; @@ -20,6 +21,10 @@ import { getTimeLeft, isRegistrationPhase, } from 'utils/challenge-detail/helper'; +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from 'utils/challenge-detail/submission-limit'; import LeftArrow from 'assets/images/arrow-prev-blue.svg'; import IconsOpenInNew from 'assets/images/open_in_new.svg'; @@ -38,6 +43,7 @@ import style from './style.scss'; /* Holds day and hour range in ms. */ const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const { fireErrorMessage } = errors; export default function ChallengeHeader(props) { const { @@ -101,6 +107,9 @@ export default function ChallengeHeader(props) { } const showDeadlineDetail = showDeadlineDetailProp; const isActivedChallenge = `${status}`.indexOf(CHALLENGE_STATUS.ACTIVE) >= 0; + const submissionLimit = getSubmissionLimit(metadata); + const isSubmissionLimitReached = submissionLimit !== null + && mySubmissions.length >= submissionLimit; const allPhases = _.filter(challenge.phases || [], p => p.name !== 'Post-Mortem'); const sortedAllPhases = _.cloneDeep(allPhases) @@ -360,7 +369,15 @@ export default function ChallengeHeader(props) { fireErrorMessage( + 'Submission Limit Reached', + getSubmissionLimitReachedMessage(submissionLimit), + ) + : undefined} + to={isSubmissionLimitReached + ? undefined + : `${challengesUrl}/${challengeId}/submit`} forceA > diff --git a/src/shared/components/challenge-detail/Specification/SideBar/index.jsx b/src/shared/components/challenge-detail/Specification/SideBar/index.jsx index fef4ed762..67e6bf90c 100644 --- a/src/shared/components/challenge-detail/Specification/SideBar/index.jsx +++ b/src/shared/components/challenge-detail/Specification/SideBar/index.jsx @@ -9,6 +9,7 @@ import { Link } from 'react-router-dom'; import { config } from 'topcoder-react-utils'; import TooltipIcon from 'assets/images/tooltip-info.svg'; +import { getSubmissionLimit } from 'utils/challenge-detail/submission-limit'; import EligibleEvents from './EligibleEvents'; // import ShareSocial from './ShareSocial'; @@ -38,14 +39,13 @@ export default function SideBar({ const faqURL = config.URL.INFO.DESIGN_CHALLENGE_SUBMISSION; let submissionLimitDisplay = 'Unlimited'; const submissionLimit = _.find(metadata, { name: 'submissionLimit' }); + const submissionLimitCount = getSubmissionLimit(metadata); const fileTypes = _.find(metadata, { name: 'fileTypes' }); - if (submissionLimit) { - if (submissionLimit.value === 1) { - submissionLimitDisplay = '1 submission'; - } else if (submissionLimit.value > 1) { - submissionLimitDisplay = `${submissionLimit.value} submissions`; - } + if (submissionLimitCount === 1) { + submissionLimitDisplay = '1 submission'; + } else if (submissionLimitCount > 1) { + submissionLimitDisplay = `${submissionLimitCount} submissions`; } const reviewTypeTitle = reviewType === 'PEER' ? 'Peer Review' : 'Community Review Board'; diff --git a/src/shared/containers/SubmissionPage.jsx b/src/shared/containers/SubmissionPage.jsx index 5f5d6f196..98183c11e 100644 --- a/src/shared/containers/SubmissionPage.jsx +++ b/src/shared/containers/SubmissionPage.jsx @@ -8,8 +8,12 @@ */ import actions from 'actions/page/submission'; import challengeDetailsActions from 'actions/page/challenge-details'; -import { actions as api } from 'topcoder-react-lib'; +import { actions as api, errors } from 'topcoder-react-lib'; import { isMM } from 'utils/challenge'; +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from 'utils/challenge-detail/submission-limit'; import communityActions from 'actions/tc-communities'; import { PrimaryButton } from 'topcoder-react-ui-kit'; import shortId from 'shortid'; @@ -19,11 +23,14 @@ import { connect } from 'react-redux'; import SubmissionsPage from 'components/SubmissionPage'; import AccessDenied, { CAUSE as ACCESS_DENIED_REASON } from 'components/tc-communities/AccessDenied'; import LoadingIndicator from 'components/LoadingIndicator'; +import { getChallengeSubmissions } from 'services/submissions'; + +const { fireErrorMessage } = errors; /** * SubmissionsPage Container */ -class SubmissionsPageContainer extends React.Component { +export class SubmissionsPageContainer extends React.Component { constructor(props) { super(props); this.handleSubmit = this.handleSubmit.bind(this); @@ -54,9 +61,17 @@ class SubmissionsPageContainer extends React.Component { } } - /* A child component has called their submitForm() prop, prepare the passed - form data for submission and create a submit action */ - handleSubmit(body) { + /** + * Verifies the member has an available slot before creating a submission. + * + * Unlimited challenges submit immediately. Limited challenges load the member's complete + * submission history so direct navigation to this page cannot bypass the header guard. + * + * @param {FormData} body Prepared submission form data. + * @return {Promise} Resolves after submission starts or the member is shown an error. + * @throws Does not throw; limit-check failures are reported to the member. + */ + async handleSubmit(body) { const { tokenV2, tokenV3, @@ -64,8 +79,35 @@ class SubmissionsPageContainer extends React.Component { challengeId, challenge, track, + metadata, + userId, } = this.props; + const submissionLimit = getSubmissionLimit(metadata); + if (submissionLimit !== null) { + try { + const existingSubmissions = await getChallengeSubmissions( + tokenV3, + challengeId, + { memberId: userId }, + ); + + if (existingSubmissions.data.length >= submissionLimit) { + fireErrorMessage( + 'Submission Limit Reached', + getSubmissionLimitReachedMessage(submissionLimit), + ); + return; + } + } catch (error) { + fireErrorMessage( + 'Unable to Verify Submission Limit', + 'We could not verify your existing submissions. Please try again.', + ); + return; + } + } + submit(tokenV3, tokenV2, challengeId, body, isMM(challenge) ? 'DEVELOP' : track); } diff --git a/src/shared/utils/challenge-detail/submission-limit.js b/src/shared/utils/challenge-detail/submission-limit.js new file mode 100644 index 000000000..eb27f27ea --- /dev/null +++ b/src/shared/utils/challenge-detail/submission-limit.js @@ -0,0 +1,115 @@ +const SUBMISSION_LIMIT_METADATA_NAME = 'submissionLimit'; + +/** + * Converts a metadata value to a positive integer submission limit. + * + * @param {*} value Raw count value. + * @return {?Number} A positive integer, or null when the value is not a valid limit. + */ +function toPositiveInteger(value) { + const numericValue = Number(value); + + if (!Number.isInteger(numericValue) || numericValue < 1) { + return null; + } + + return numericValue; +} + +/** + * Checks whether a legacy metadata flag is explicitly enabled. + * + * @param {*} value Raw flag value. + * @return {Boolean} Whether the value represents true. + */ +function isTrue(value) { + return value === true || value === 'true'; +} + +/** + * Extracts a limited count from parsed submission-limit metadata. + * + * Explicit unlimited and disabled-limit payloads remain unlimited. A count with no legacy flags + * is accepted for compatibility with older metadata shapes. + * + * @param {*} value Parsed metadata value. + * @return {?Number} The configured submission limit, or null for unlimited/invalid metadata. + */ +function extractSubmissionLimit(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return toPositiveInteger(value); + } + + const count = toPositiveInteger(value.count); + const hasLimitFlag = Object.prototype.hasOwnProperty.call(value, 'limit'); + const hasUnlimitedFlag = Object.prototype.hasOwnProperty.call(value, 'unlimited'); + + if (isTrue(value.limit)) { + return count; + } + + if (isTrue(value.unlimited) || hasLimitFlag || hasUnlimitedFlag) { + return null; + } + + return count; +} + +/** + * Reads the configured submission limit from challenge metadata. + * + * Supports the current JSON-string contract and older numeric values. Missing, malformed, and + * unlimited metadata resolve to null so callers can use the Unlimited display/behavior. + * + * @param {Array} metadata Challenge metadata entries. + * @return {?Number} The positive submission limit, or null when submissions are unlimited. + */ +export function getSubmissionLimit(metadata) { + if (!Array.isArray(metadata)) { + return null; + } + + const submissionLimit = metadata.find(entry => ( + entry && entry.name === SUBMISSION_LIMIT_METADATA_NAME + )); + + if (!submissionLimit) { + return null; + } + + const rawValue = submissionLimit.value; + + if (typeof rawValue !== 'string') { + return extractSubmissionLimit(rawValue); + } + + const normalizedValue = rawValue.trim(); + + if (!normalizedValue) { + return null; + } + + try { + return extractSubmissionLimit(JSON.parse(normalizedValue)); + } catch (error) { + return toPositiveInteger(normalizedValue); + } +} + +/** + * Builds the message shown when a member has no remaining submission slots. + * + * @param {Number} submissionLimit Configured active submission limit. + * @return {String} Message explaining how the member can replace a submission. + */ +export function getSubmissionLimitReachedMessage(submissionLimit) { + if (submissionLimit === 1) { + return 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.'; + } + + return `This challenge allows only ${submissionLimit} submissions, and you've already reached that limit.` + + ' To replace one, delete an existing submission first.'; +} + +export default getSubmissionLimit;