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