diff --git a/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx b/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx
index 097d67092..cf5ce276f 100644
--- a/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx
+++ b/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx
@@ -1,20 +1,76 @@
-// import React from 'react';
-// import Renderer from 'react-test-renderer/shallow';
-// import SubmissionManagement from 'components/SubmissionManagement/SubmissionManagement';
-
-// FIXME: Fix the tests for the settings page
-test('Matches shallow shapshot', () => {
- expect(true).toBeTruthy();
- // const renderer = new Renderer();
- // renderer.render((
- //
- // ));
- // expect(renderer.getRenderOutput()).toMatchSnapshot();
+import { shallow } from 'enzyme';
+import React from 'react';
+import { PrimaryButton } from 'topcoder-react-ui-kit';
+
+import SubmissionManagement from 'components/SubmissionManagement/SubmissionManagement';
+
+/**
+ * Renders the regular My Submissions page with an open Design upload phase.
+ *
+ * @param {Object} propOverrides Optional component prop replacements.
+ * @return {ShallowWrapper} Rendered Submission Management component.
+ * @throws {Error} Propagates errors raised while shallow-rendering the component.
+ */
+function renderSubmissionManagement(propOverrides = {}) {
+ return shallow(
+ ,
+ );
+}
+
+describe('Submission Management Add Submission action', () => {
+ test('routes through the authoritative limit handler', () => {
+ const onAddSubmission = jest.fn();
+ const wrapper = renderSubmissionManagement({ onAddSubmission });
+ const button = wrapper.find(PrimaryButton).last();
+
+ expect(button.prop('to')).toBe('/challenges/challenge-id/submit');
+ expect(button.prop('onClick')).toBe(onAddSubmission);
+ expect(button.prop('disabled')).toBe(false);
+ });
+
+ test('supports checkpoint uploads on the regular My Submissions route', () => {
+ const wrapper = renderSubmissionManagement({
+ challenge: {
+ name: 'Design challenge',
+ phases: [{
+ isOpen: true,
+ name: 'Checkpoint Submission',
+ scheduledEndDate: '2030-08-20T00:00:00.000Z',
+ scheduledStartDate: '2030-08-10T00:00:00.000Z',
+ }],
+ status: 'ACTIVE',
+ track: 'Design',
+ },
+ });
+
+ expect(wrapper.find(PrimaryButton)).toHaveLength(1);
+ });
+
+ test('disables direct routing while the limit lookup is pending', () => {
+ const wrapper = renderSubmissionManagement({
+ submissionLimitCheckPending: true,
+ });
+ const button = wrapper.find(PrimaryButton).last();
+
+ expect(button.prop('disabled')).toBe(true);
+ expect(button.prop('to')).toBeNull();
+ });
});
diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx
index 5270b4962..24c2b9a27 100644
--- a/__tests__/shared/components/challenge-detail/Header/index.jsx
+++ b/__tests__/shared/components/challenge-detail/Header/index.jsx
@@ -116,6 +116,7 @@ function renderHeader(challengeOverrides = {}, propOverrides = {}) {
numWinners={1}
onSelectorClicked={jest.fn()}
onSort={jest.fn()}
+ onSubmitChallenge={jest.fn()}
onToggleDeadlines={jest.fn()}
openForRegistrationChallenges={{}}
registerForChallenge={jest.fn()}
@@ -190,9 +191,10 @@ describe('Challenge detail header actions', () => {
unlimited: 'false',
}),
}],
+ track: 'Design',
}, {
hasRegistered: true,
- mySubmissions: [{ id: 'submission-id' }],
+ mySubmissions: [{ id: 'submission-id', type: 'CONTEST_SUBMISSION' }],
});
const submitAction = findSubmitAction(output);
@@ -207,6 +209,7 @@ describe('Challenge detail header actions', () => {
});
test('keeps the submission page available while slots remain', () => {
+ const onSubmitChallenge = jest.fn();
const output = renderHeader({
metadata: [{
name: 'submissionLimit',
@@ -216,16 +219,81 @@ describe('Challenge detail header actions', () => {
unlimited: 'false',
}),
}],
+ track: 'Design',
}, {
hasRegistered: true,
- mySubmissions: [{ id: 'submission-id' }],
+ mySubmissions: [{ id: 'submission-id', type: 'CONTEST_SUBMISSION' }],
+ onSubmitChallenge,
});
const submitAction = findSubmitAction(output);
expect(submitAction.props.to).toBe('/challenges/challenge-id/submit');
- expect(submitAction.props.onClick).toBeUndefined();
+ expect(submitAction.props.onClick).toBe(onSubmitChallenge);
expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled();
});
+
+ test('does not count a checkpoint submission against the contest limit', () => {
+ const onSubmitChallenge = jest.fn();
+ const output = renderHeader({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [
+ { isOpen: false, name: 'Checkpoint Submission' },
+ { isOpen: true, name: 'Submission' },
+ ],
+ track: 'Design',
+ }, {
+ hasRegistered: true,
+ mySubmissions: [{ id: 'submission-id', type: 'CHECKPOINT_SUBMISSION' }],
+ onSubmitChallenge,
+ });
+ const submitAction = findSubmitAction(output);
+
+ expect(submitAction.props.to).toBe('/challenges/challenge-id/submit');
+ expect(submitAction.props.onClick).toBe(onSubmitChallenge);
+ expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled();
+ });
+
+ test('does not apply Design submission-limit metadata to Development challenges', () => {
+ const onSubmitChallenge = jest.fn();
+ const output = renderHeader({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ track: 'Development',
+ }, {
+ hasRegistered: true,
+ mySubmissions: [{ id: 'submission-id', type: 'CONTEST_SUBMISSION' }],
+ onSubmitChallenge,
+ });
+ const submitAction = findSubmitAction(output);
+
+ expect(submitAction.props.to).toBe('/challenges/challenge-id/submit');
+ expect(submitAction.props.onClick).toBe(onSubmitChallenge);
+ expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled();
+ });
+
+ test('disables submit navigation while the authoritative limit check is pending', () => {
+ const output = renderHeader({}, {
+ hasRegistered: true,
+ submissionLimitCheckPending: true,
+ });
+ const submitAction = findSubmitAction(output);
+
+ expect(submitAction.props.disabled).toBe(true);
+ expect(submitAction.props.to).toBeUndefined();
+ });
});
describe('Challenge detail tab counts', () => {
diff --git a/__tests__/shared/containers/SubmissionManagement.jsx b/__tests__/shared/containers/SubmissionManagement.jsx
index 2758d558f..7e8bfc35d 100644
--- a/__tests__/shared/containers/SubmissionManagement.jsx
+++ b/__tests__/shared/containers/SubmissionManagement.jsx
@@ -4,8 +4,177 @@
* modification of SubmissionManagement component, that required to wrap
* it into element. No time to properly fix it now, thus
* just commented out. */
+import { SubmissionManagementPageContainer } from 'containers/SubmissionManagement';
+import { getChallengeSubmissions as mockedGetChallengeSubmissions } from 'services/submissions';
+
+jest.mock('services/submissions', () => ({
+ downloadSubmissions: jest.fn(),
+ getChallengeSubmissions: jest.fn(),
+ getSubmissionArtifacts: jest.fn(),
+ getSubmissionDownloadUrl: jest.fn(),
+}));
+
test.skip('Placeholder', () => {});
+/**
+ * Creates a mounted Submission Management container with Design limit defaults.
+ *
+ * @param {Object} propOverrides Optional container prop replacements.
+ * @return {{container: SubmissionManagementPageContainer, props: Object}} Test container and props.
+ * @throws Does not throw.
+ */
+function createSubmissionManagementContainer(propOverrides = {}) {
+ const props = {
+ authTokens: {
+ tokenV3: 'token-v3',
+ user: { userId: 'member-id' },
+ },
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Submission' }],
+ track: 'Design',
+ },
+ challengeId: 'challenge-id',
+ challengesUrl: '/challenges',
+ history: { push: jest.fn() },
+ mySubmissions: [],
+ ...propOverrides,
+ };
+ const container = new SubmissionManagementPageContainer(props);
+ container.isComponentMounted = true;
+ container.setState = jest.fn((state, callback) => {
+ container.state = { ...container.state, ...state };
+ if (callback) callback();
+ });
+
+ return { container, props };
+}
+
+describe('Submission Management Add Submission limit', () => {
+ beforeEach(() => {
+ mockedGetChallengeSubmissions.mockReset();
+ });
+
+ test('blocks the regular Design route when service history reaches the contest limit', async () => {
+ mockedGetChallengeSubmissions.mockResolvedValue({
+ data: [{ id: 'contest-submission' }],
+ });
+ const { container, props } = createSubmissionManagementContainer();
+ const event = { preventDefault: jest.fn() };
+
+ await container.onAddSubmission(event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith(
+ 'token-v3',
+ 'challenge-id',
+ {
+ memberId: 'member-id',
+ type: 'CONTEST_SUBMISSION',
+ },
+ );
+ expect(props.history.push).not.toHaveBeenCalled();
+ });
+
+ test('routes when complete checkpoint history confirms a slot remains', async () => {
+ mockedGetChallengeSubmissions.mockResolvedValue({ data: [] });
+ const { container, props } = createSubmissionManagementContainer({
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Checkpoint Submission' }],
+ track: 'Design',
+ },
+ });
+
+ await container.onAddSubmission({ preventDefault: jest.fn() });
+
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith(
+ 'token-v3',
+ 'challenge-id',
+ {
+ memberId: 'member-id',
+ type: 'CHECKPOINT_SUBMISSION',
+ },
+ );
+ expect(props.history.push).toHaveBeenCalledWith('/challenges/challenge-id/submit');
+ });
+
+ test('fails closed and releases the pending guard when history lookup fails', async () => {
+ mockedGetChallengeSubmissions.mockRejectedValue(new Error('network error'));
+ const { container, props } = createSubmissionManagementContainer();
+
+ await container.onAddSubmission({ preventDefault: jest.fn() });
+
+ expect(props.history.push).not.toHaveBeenCalled();
+ expect(container.submissionLimitCheckPending).toBe(false);
+ expect(container.state.submissionLimitCheckPending).toBe(false);
+ });
+
+ test('leaves unlimited, non-Design, and final-fix links to normal navigation', async () => {
+ const { container: unlimitedContainer } = createSubmissionManagementContainer({
+ challenge: {
+ metadata: [],
+ phases: [{ isOpen: true, name: 'Submission' }],
+ track: 'Design',
+ },
+ });
+ const { container: finalFixContainer } = createSubmissionManagementContainer({
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Final Fix' }],
+ track: 'Design',
+ },
+ });
+ const { container: developmentContainer } = createSubmissionManagementContainer({
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Submission' }],
+ track: 'Development',
+ },
+ });
+ const unlimitedEvent = { preventDefault: jest.fn() };
+ const finalFixEvent = { preventDefault: jest.fn() };
+ const developmentEvent = { preventDefault: jest.fn() };
+
+ await unlimitedContainer.onAddSubmission(unlimitedEvent);
+ await finalFixContainer.onAddSubmission(finalFixEvent);
+ await developmentContainer.onAddSubmission(developmentEvent);
+
+ expect(unlimitedEvent.preventDefault).not.toHaveBeenCalled();
+ expect(finalFixEvent.preventDefault).not.toHaveBeenCalled();
+ expect(developmentEvent.preventDefault).not.toHaveBeenCalled();
+ expect(mockedGetChallengeSubmissions).not.toHaveBeenCalled();
+ });
+});
+
/*
import _ from 'lodash';
import React from 'react';
diff --git a/__tests__/shared/containers/SubmissionPage.jsx b/__tests__/shared/containers/SubmissionPage.jsx
index 9d8541e1b..9935dbc9f 100644
--- a/__tests__/shared/containers/SubmissionPage.jsx
+++ b/__tests__/shared/containers/SubmissionPage.jsx
@@ -46,7 +46,9 @@ function createContainerProps(overrides = {}) {
return {
challenge: {},
challengeId: 'challenge-id',
+ isSubmitting: false,
metadata: [],
+ phases: [{ isOpen: true, name: 'Submission' }],
submit: jest.fn(),
tokenV2: 'token-v2',
tokenV3: 'token-v3',
@@ -78,6 +80,60 @@ describe('SubmissionsPageContainer submission limits', () => {
);
});
+ test('does not apply Design submission-limit metadata to Development uploads', async () => {
+ const props = createContainerProps({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ track: 'Development',
+ });
+ 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,
+ 'Development',
+ );
+ });
+
+ test('skips the concept limit lookup during final fix', async () => {
+ const props = createContainerProps({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Final Fix' }],
+ });
+ 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' }],
@@ -100,7 +156,10 @@ describe('SubmissionsPageContainer submission limits', () => {
expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith(
'token-v3',
'challenge-id',
- { memberId: 'member-id' },
+ {
+ memberId: 'member-id',
+ type: 'CONTEST_SUBMISSION',
+ },
);
expect(props.submit).toHaveBeenCalled();
expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled();
@@ -154,4 +213,63 @@ describe('SubmissionsPageContainer submission limits', () => {
'We could not verify your existing submissions. Please try again.',
);
});
+
+ test('checks checkpoint submissions separately from contest submissions', async () => {
+ mockedGetChallengeSubmissions.mockResolvedValue({ data: [] });
+ const props = createContainerProps({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [
+ { isOpen: true, name: 'Checkpoint Submission' },
+ { isOpen: false, name: 'Submission' },
+ ],
+ });
+ const container = new SubmissionsPageContainer(props);
+
+ await container.handleSubmit({});
+
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith(
+ 'token-v3',
+ 'challenge-id',
+ {
+ memberId: 'member-id',
+ type: 'CHECKPOINT_SUBMISSION',
+ },
+ );
+ expect(props.submit).toHaveBeenCalledTimes(1);
+ });
+
+ test('ignores a concurrent submission while the limit check is pending', async () => {
+ let resolveSubmissions;
+ mockedGetChallengeSubmissions.mockReturnValue(new Promise((resolve) => {
+ resolveSubmissions = resolve;
+ }));
+ const props = createContainerProps({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '2',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ });
+ const container = new SubmissionsPageContainer(props);
+
+ const firstSubmission = container.handleSubmit({ id: 'first' });
+ const concurrentSubmission = container.handleSubmit({ id: 'second' });
+
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledTimes(1);
+ resolveSubmissions({ data: [{ id: 'existing-submission' }] });
+ await Promise.all([firstSubmission, concurrentSubmission]);
+
+ expect(props.submit).toHaveBeenCalledTimes(1);
+ expect(props.submit.mock.calls[0][3]).toEqual({ id: 'first' });
+ });
});
diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx
index 59b7497c4..d93801f65 100644
--- a/__tests__/shared/containers/challenge-detail/index.jsx
+++ b/__tests__/shared/containers/challenge-detail/index.jsx
@@ -1,5 +1,6 @@
import {
buildChallengeLoginUrl,
+ ChallengeDetailPageContainer,
getDisplayWinners,
isGroupedChallenge,
isGroupedChallengeAccessError,
@@ -8,6 +9,53 @@ import {
shouldLoginForGroupedChallenge,
shouldLoginForGroupedChallengeError,
} from 'containers/challenge-detail';
+import { getChallengeSubmissions as mockedGetChallengeSubmissions } from 'services/submissions';
+
+jest.mock('services/submissions', () => ({
+ getChallengeSubmissions: jest.fn(),
+ getSubmissionArtifacts: jest.fn(),
+}));
+
+/**
+ * Creates the minimal challenge-detail context needed to exercise submit navigation.
+ *
+ * @param {Object} propOverrides Optional container prop replacements.
+ * @return {Object} Method context with mocked state updates and navigation.
+ * @throws Does not throw.
+ */
+function createSubmitNavigationContext(propOverrides = {}) {
+ const context = {
+ props: {
+ auth: {
+ tokenV3: 'token-v3',
+ user: { userId: 'member-id' },
+ },
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Submission' }],
+ track: 'Design',
+ },
+ challengeId: 'challenge-id',
+ challengesUrl: '/challenges',
+ history: { push: jest.fn() },
+ mySubmissions: [],
+ ...propOverrides,
+ },
+ setState: jest.fn((state, callback) => {
+ if (callback) callback();
+ }),
+ submissionLimitCheckPending: false,
+ };
+
+ return context;
+}
describe('Challenge detail Wipro registration guard', () => {
test('blocks Wipro members when challenge disallows Wipro participation', () => {
@@ -135,6 +183,126 @@ describe('Challenge detail My Submissions count', () => {
});
});
+describe('Challenge detail submit navigation limit', () => {
+ beforeEach(() => {
+ mockedGetChallengeSubmissions.mockReset();
+ });
+
+ test('blocks navigation when complete history reaches the limit but local history is empty', async () => {
+ mockedGetChallengeSubmissions.mockResolvedValue({
+ data: [{ id: 'contest-submission' }],
+ });
+ const context = createSubmitNavigationContext();
+ const event = { preventDefault: jest.fn() };
+
+ await ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(context, event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith(
+ 'token-v3',
+ 'challenge-id',
+ {
+ memberId: 'member-id',
+ type: 'CONTEST_SUBMISSION',
+ },
+ );
+ expect(context.props.history.push).not.toHaveBeenCalled();
+ expect(context.submissionLimitCheckPending).toBe(false);
+ });
+
+ test('navigates after complete history confirms a submission slot remains', async () => {
+ mockedGetChallengeSubmissions.mockResolvedValue({ data: [] });
+ const context = createSubmitNavigationContext();
+ const event = { preventDefault: jest.fn() };
+
+ await ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(context, event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(context.props.history.push)
+ .toHaveBeenCalledWith('/challenges/challenge-id/submit');
+ expect(context.submissionLimitCheckPending).toBe(false);
+ });
+
+ test('leaves unlimited and final-fix links to their normal navigation', async () => {
+ const unlimitedContext = createSubmitNavigationContext({
+ challenge: {
+ metadata: [],
+ phases: [{ isOpen: true, name: 'Submission' }],
+ track: 'Design',
+ },
+ });
+ const finalFixContext = createSubmitNavigationContext({
+ challenge: {
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+ }],
+ phases: [{ isOpen: true, name: 'Final Fix' }],
+ track: 'Design',
+ },
+ });
+ const unlimitedEvent = { preventDefault: jest.fn() };
+ const finalFixEvent = { preventDefault: jest.fn() };
+
+ await ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(
+ unlimitedContext,
+ unlimitedEvent,
+ );
+ await ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(
+ finalFixContext,
+ finalFixEvent,
+ );
+
+ expect(unlimitedEvent.preventDefault).not.toHaveBeenCalled();
+ expect(finalFixEvent.preventDefault).not.toHaveBeenCalled();
+ expect(mockedGetChallengeSubmissions).not.toHaveBeenCalled();
+ });
+
+ test('fails closed and releases the click guard when complete-history lookup fails', async () => {
+ mockedGetChallengeSubmissions.mockRejectedValue(new Error('network error'));
+ const context = createSubmitNavigationContext();
+
+ await ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(
+ context,
+ { preventDefault: jest.fn() },
+ );
+
+ expect(context.props.history.push).not.toHaveBeenCalled();
+ expect(context.submissionLimitCheckPending).toBe(false);
+ expect(context.setState).toHaveBeenLastCalledWith(
+ { submissionLimitCheckPending: false },
+ expect.any(Function),
+ );
+ });
+
+ test('ignores repeated clicks while the complete-history lookup is pending', async () => {
+ let resolveSubmissions;
+ mockedGetChallengeSubmissions.mockReturnValue(new Promise((resolve) => {
+ resolveSubmissions = resolve;
+ }));
+ const context = createSubmitNavigationContext();
+
+ const firstClick = ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(
+ context,
+ { preventDefault: jest.fn() },
+ );
+ const repeatedClick = ChallengeDetailPageContainer.prototype.onSubmitChallenge.call(
+ context,
+ { preventDefault: jest.fn() },
+ );
+
+ expect(mockedGetChallengeSubmissions).toHaveBeenCalledTimes(1);
+ resolveSubmissions({ data: [] });
+ await Promise.all([firstClick, repeatedClick]);
+
+ expect(context.props.history.push).toHaveBeenCalledTimes(1);
+ });
+});
+
describe('Challenge detail grouped challenge login guard', () => {
beforeEach(() => {
document.cookie = 'tc_utm=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
diff --git a/__tests__/shared/services/submissions.js b/__tests__/shared/services/submissions.js
index bacffb1db..e771b4fa8 100644
--- a/__tests__/shared/services/submissions.js
+++ b/__tests__/shared/services/submissions.js
@@ -138,7 +138,7 @@ describe('submissions service', () => {
expect(result.data).toEqual([{ id: 'submission-only-page' }]);
});
- it('passes latest and member filters to the submissions API', async () => {
+ it('passes latest, member, and submission type filters to the submissions API', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
@@ -154,11 +154,12 @@ describe('submissions service', () => {
await getChallengeSubmissions('token-v3', 'challenge-id', {
isLatest: true,
memberId: '1001',
+ type: 'CHECKPOINT_SUBMISSION',
});
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
- `${baseUrl}?challengeId=challenge-id&perPage=500&page=1&isLatest=true&memberId=1001`,
+ `${baseUrl}?challengeId=challenge-id&perPage=500&page=1&isLatest=true&memberId=1001&type=CHECKPOINT_SUBMISSION`,
expect.objectContaining({ method: 'GET' }),
);
});
diff --git a/__tests__/shared/utils/challenge-detail/submission-limit.test.js b/__tests__/shared/utils/challenge-detail/submission-limit.test.js
index 80281bf86..20092d134 100644
--- a/__tests__/shared/utils/challenge-detail/submission-limit.test.js
+++ b/__tests__/shared/utils/challenge-detail/submission-limit.test.js
@@ -1,9 +1,21 @@
/* eslint-env jest */
import {
+ getActiveSubmissionCount,
+ getActiveSubmissionType,
getSubmissionLimit,
getSubmissionLimitReachedMessage,
+ hasReachedSubmissionLimit,
} from '../../../../src/shared/utils/challenge-detail/submission-limit';
+const LIMITED_TO_ONE_METADATA = [{
+ name: 'submissionLimit',
+ value: JSON.stringify({
+ count: '1',
+ limit: 'true',
+ unlimited: 'false',
+ }),
+}];
+
describe('getSubmissionLimit', () => {
test('returns null when submission-limit metadata is missing', () => {
expect(getSubmissionLimit([])).toBeNull();
@@ -58,6 +70,68 @@ describe('getSubmissionLimit', () => {
});
});
+describe('active submission phase limits', () => {
+ test('resolves checkpoint and contest submission types independently', () => {
+ expect(getActiveSubmissionType([
+ { isOpen: true, name: 'Checkpoint Submission' },
+ { isOpen: false, name: 'Submission' },
+ ])).toBe('CHECKPOINT_SUBMISSION');
+ expect(getActiveSubmissionType([
+ { isOpen: false, name: 'Checkpoint Submission' },
+ { isOpen: true, name: 'Submission' },
+ ])).toBe('CONTEST_SUBMISSION');
+ });
+
+ test('does not count checkpoint submissions against the contest limit', () => {
+ const phases = [
+ { isOpen: false, name: 'Checkpoint Submission' },
+ { isOpen: true, name: 'Submission' },
+ ];
+ const submissions = [{
+ id: 'checkpoint-submission',
+ type: 'CHECKPOINT_SUBMISSION',
+ }];
+
+ expect(getActiveSubmissionCount(submissions, phases)).toBe(0);
+ expect(hasReachedSubmissionLimit(
+ LIMITED_TO_ONE_METADATA,
+ submissions,
+ phases,
+ )).toBe(false);
+ });
+
+ test('counts current and legacy submissions from the active phase', () => {
+ const phases = [{ isOpen: true, name: 'Checkpoint Submission' }];
+ const submissions = [
+ { id: 'current-checkpoint', type: 'CHECKPOINT_SUBMISSION' },
+ { id: 'legacy-checkpoint', submissionType: 'checkpoint' },
+ { id: 'contest-submission', type: 'CONTEST_SUBMISSION' },
+ ];
+
+ expect(getActiveSubmissionCount(submissions, phases)).toBe(2);
+ expect(hasReachedSubmissionLimit(
+ LIMITED_TO_ONE_METADATA,
+ submissions,
+ phases,
+ )).toBe(true);
+ });
+
+ test('does not apply concept limits during final fix', () => {
+ const phases = [{ isOpen: true, name: 'Final Fix' }];
+ const submissions = [{
+ id: 'final-fix-submission',
+ type: 'STUDIO_FINAL_FIX_SUBMISSION',
+ }];
+
+ expect(getActiveSubmissionCount(submissions, phases)).toBe(0);
+ expect(hasReachedSubmissionLimit(
+ LIMITED_TO_ONE_METADATA,
+ submissions,
+ phases,
+ )).toBe(false);
+ });
+});
+
describe('getSubmissionLimitReachedMessage', () => {
test('uses the requested singular limit message', () => {
expect(getSubmissionLimitReachedMessage(1)).toBe(
diff --git a/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx b/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx
index c0f962fe3..2eaa362d7 100644
--- a/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx
+++ b/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx
@@ -10,7 +10,7 @@
* onDownload() (to be triggered by download icon)
* onOpenOnlineReview(submissionId); onHelp(submissionId);
* onShowDetails(submissionId);
- * onSubmit() - to trigger when user clicks Add Submission button.
+ * onAddSubmission() - to verify the active submission limit before opening the upload page.
*/
import _ from 'lodash';
@@ -44,6 +44,8 @@ export default function SubmissionManagement(props) {
onDownloadArtifacts,
getSubmissionArtifacts,
getSubmissionScores,
+ onAddSubmission,
+ submissionLimitCheckPending,
} = props;
const { track } = challenge;
@@ -56,9 +58,11 @@ export default function SubmissionManagement(props) {
const currentPhase = challenge.phases
.filter(p => p.name !== 'Registration' && p.isOpen)
.sort((a, b) => moment(a.scheduledEndDate).diff(b.scheduledEndDate))[0];
- const submissionPhase = challenge.phases.filter(p => p.name === 'Submission')[0];
+ const submissionPhase = challenge.phases.find(
+ phase => phase.name === 'Checkpoint Submission' && phase.isOpen,
+ ) || challenge.phases.find(phase => phase.name === 'Submission' && phase.isOpen);
const submissionEndDate = submissionPhase && phaseEndDate(submissionPhase);
- const isSubmissionPhaseOpen = Boolean(submissionPhase && submissionPhase.isOpen);
+ const isSubmissionPhaseOpen = Boolean(submissionPhase);
const now = moment();
const end = moment(currentPhase && currentPhase.scheduledEndDate);
@@ -196,10 +200,12 @@ export default function SubmissionManagement(props) {
{isSubmissionPhaseOpen && now.isBefore(submissionEndDate) && (
{
(!isDevelop || !submissions || submissions.length === 0)
@@ -219,11 +225,13 @@ SubmissionManagement.defaultProps = {
onDownloadArtifacts: _.noop,
getSubmissionArtifacts: _.noop,
getSubmissionScores: _.noop,
+ onAddSubmission: _.noop,
onlineReviewUrl: '',
helpPageUrl: '',
loadingSubmissions: false,
challengeUrl: '',
submissions: [],
+ submissionLimitCheckPending: false,
};
SubmissionManagement.propTypes = {
@@ -238,7 +246,9 @@ SubmissionManagement.propTypes = {
onDownloadArtifacts: PT.func,
getSubmissionArtifacts: PT.func,
getSubmissionScores: PT.func,
+ onAddSubmission: PT.func,
submissions: PT.arrayOf(PT.shape()),
+ submissionLimitCheckPending: PT.bool,
loadingSubmissions: PT.bool,
challengeUrl: PT.string,
submissionPhaseStartDate: PT.string.isRequired,
diff --git a/src/shared/components/SubmissionPage/Submit/index.jsx b/src/shared/components/SubmissionPage/Submit/index.jsx
index 330c7165a..99c3fd6be 100644
--- a/src/shared/components/SubmissionPage/Submit/index.jsx
+++ b/src/shared/components/SubmissionPage/Submit/index.jsx
@@ -16,6 +16,7 @@ import { PrimaryButton } from 'topcoder-react-ui-kit';
import { config } from 'topcoder-react-utils';
import LoadingIndicator from 'components/LoadingIndicator';
import { COMPETITION_TRACKS } from 'utils/tc';
+import { getActiveSubmissionType } from 'utils/challenge-detail/submission-limit';
import FilestackFilePicker from '../FilestackFilePicker';
@@ -97,10 +98,11 @@ class Submit extends React.Component {
const {
submissionFilestackData: sub,
challengeId,
+ phases,
userId,
} = this.props;
- const subType = this.getSubDetails();
+ const subType = getActiveSubmissionType(phases);
const formData = new FormData();
formData.append('url', sub.fileUrl);
@@ -113,36 +115,6 @@ class Submit extends React.Component {
return formData;
}
- // returns both submission type and phase id
- getSubDetails() {
- const {
- phases,
- } = this.props;
- const checkpoint = _.find(phases, {
- name: 'Checkpoint Submission',
- });
- const submission = _.find(phases, {
- name: 'Submission',
- });
- const finalFix = _.find(phases, {
- name: 'Final Fix',
- });
- let subType;
-
- // Submission type logic
- if (checkpoint && checkpoint.isOpen) {
- subType = 'CHECKPOINT_SUBMISSION';
- } else if (checkpoint && !checkpoint.isOpen && submission && submission.isOpen) {
- subType = 'CONTEST_SUBMISSION';
- } else if (finalFix && finalFix.isOpen) {
- subType = 'STUDIO_FINAL_FIX_SUBMISSION';
- } else {
- subType = 'CONTEST_SUBMISSION';
- }
-
- return subType;
- }
-
reset() {
const { resetForm, setAgreed } = this.props;
setAgreed(false);
diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx
index 9f6786431..ede52e88d 100644
--- a/src/shared/components/challenge-detail/Header/index.jsx
+++ b/src/shared/components/challenge-detail/Header/index.jsx
@@ -24,6 +24,7 @@ import {
import {
getSubmissionLimit,
getSubmissionLimitReachedMessage,
+ hasReachedSubmissionLimit,
} from 'utils/challenge-detail/submission-limit';
import LeftArrow from 'assets/images/arrow-prev-blue.svg';
@@ -70,8 +71,10 @@ export default function ChallengeHeader(props) {
submissionEnded,
mySubmissions,
mySubmissionsCount,
+ onSubmitChallenge,
openForRegistrationChallenges,
onSort,
+ submissionLimitCheckPending,
viewAsTable,
} = props;
@@ -108,8 +111,12 @@ 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 isSubmissionLimitReached = _.toLower(getTrackName(track)) === 'design'
+ && hasReachedSubmissionLimit(
+ metadata,
+ mySubmissions,
+ challenge.phases,
+ );
const allPhases = _.filter(challenge.phases || [], p => p.name !== 'Post-Mortem');
const sortedAllPhases = _.cloneDeep(allPhases)
@@ -328,6 +335,7 @@ export default function ChallengeHeader(props) {
}
const disabled = !hasRegistered || unregistering || submissionEnded || isLegacyMM;
+ const submitDisabled = disabled || submissionLimitCheckPending;
const registerButtonDisabled = registering
|| registrationEnded
|| isLegacyMM
@@ -367,15 +375,15 @@ export default function ChallengeHeader(props) {
)}
fireErrorMessage(
'Submission Limit Reached',
getSubmissionLimitReachedMessage(submissionLimit),
)
- : undefined}
- to={isSubmissionLimitReached
+ : onSubmitChallenge}
+ to={isSubmissionLimitReached || submissionLimitCheckPending
? undefined
: `${challengesUrl}/${challengeId}/submit`}
forceA
@@ -605,6 +613,7 @@ ChallengeHeader.defaultProps = {
hasThriveArticles: false,
hasRecommendedChallenges: false,
mySubmissionsCount: null,
+ submissionLimitCheckPending: false,
};
ChallengeHeader.propTypes = {
@@ -660,7 +669,9 @@ ChallengeHeader.propTypes = {
isMenuOpened: PT.bool,
mySubmissions: PT.arrayOf(PT.shape()).isRequired,
mySubmissionsCount: PT.number,
+ onSubmitChallenge: PT.func.isRequired,
openForRegistrationChallenges: PT.shape().isRequired,
onSort: PT.func.isRequired,
+ submissionLimitCheckPending: PT.bool,
viewAsTable: PT.bool.isRequired,
};
diff --git a/src/shared/containers/SubmissionManagement/index.jsx b/src/shared/containers/SubmissionManagement/index.jsx
index 047637e43..13f19e5b6 100644
--- a/src/shared/containers/SubmissionManagement/index.jsx
+++ b/src/shared/containers/SubmissionManagement/index.jsx
@@ -12,13 +12,22 @@ import SubmissionManagement from 'components/SubmissionManagement/SubmissionMana
import React from 'react';
import PT from 'prop-types';
import { safeForDownload } from 'utils/tc';
+import { getTrackName } from 'utils/challenge';
+import {
+ getActiveSubmissionType,
+ getSubmissionLimit,
+ getSubmissionLimitReachedMessage,
+ hasReachedSubmissionLimit,
+ isSubmissionLimitType,
+} from 'utils/challenge-detail/submission-limit';
import { connect } from 'react-redux';
import { Modal, PrimaryButton } from 'topcoder-react-ui-kit';
import { config } from 'topcoder-react-utils';
-import { actions } from 'topcoder-react-lib';
+import { actions, errors } from 'topcoder-react-lib';
import getReviewSummationsService from 'services/reviewSummations';
import {
downloadSubmissions,
+ getChallengeSubmissions,
getSubmissionArtifacts,
getSubmissionDownloadUrl,
} from 'services/submissions';
@@ -146,9 +155,10 @@ const buildScoreEntries = (summations = []) => {
const theme = {
container: style.modalContainer,
};
+const { fireErrorMessage } = errors;
// The container component
-class SubmissionManagementPageContainer extends React.Component {
+export class SubmissionManagementPageContainer extends React.Component {
constructor(props) {
super(props);
@@ -162,7 +172,10 @@ class SubmissionManagementPageContainer extends React.Component {
initialState: true,
submissions: [],
reviewSummationsBySubmission: {},
+ submissionLimitCheckPending: false,
};
+
+ this.submissionLimitCheckPending = false;
}
componentDidMount() {
@@ -272,8 +285,106 @@ class SubmissionManagementPageContainer extends React.Component {
componentWillUnmount() {
this.isComponentMounted = false;
this.pendingReviewSummationChallengeId = null;
+ this.submissionLimitCheckPending = false;
}
+ /**
+ * Verifies complete Design submission history before opening the upload page.
+ *
+ * Finite checkpoint and contest limits are checked against all matching submissions for the
+ * current member. Unlimited, non-Design, and final-fix links retain normal navigation. Repeated
+ * clicks are ignored while the authoritative lookup is pending.
+ *
+ * @param {Object} event Add Submission link click event.
+ * @return {Promise} Resolves after navigation starts or an explanatory modal is shown.
+ * @throws Does not throw; lookup failures are reported to the member.
+ */
+ onAddSubmission = async (event) => {
+ const {
+ authTokens,
+ challenge,
+ challengeId,
+ challengesUrl,
+ history,
+ mySubmissions,
+ } = this.props;
+ const submissionLimit = getSubmissionLimit(challenge.metadata);
+ const submissionType = getActiveSubmissionType(challenge.phases);
+ const isDesign = _.toLower(getTrackName(challenge.track)) === 'design';
+
+ if (!isDesign || submissionLimit === null || !isSubmissionLimitType(submissionType)) {
+ return;
+ }
+
+ if (event && event.preventDefault) {
+ event.preventDefault();
+ }
+
+ if (this.submissionLimitCheckPending) {
+ return;
+ }
+
+ if (hasReachedSubmissionLimit(challenge.metadata, mySubmissions, challenge.phases)) {
+ fireErrorMessage(
+ 'Submission Limit Reached',
+ getSubmissionLimitReachedMessage(submissionLimit),
+ );
+ return;
+ }
+
+ const memberId = _.get(authTokens, 'user.userId');
+ if (!authTokens.tokenV3 || _.isNil(memberId)) {
+ fireErrorMessage(
+ 'Unable to Verify Submission Limit',
+ 'We could not verify your existing submissions. Please try again.',
+ );
+ return;
+ }
+
+ this.submissionLimitCheckPending = true;
+ this.setState({ submissionLimitCheckPending: true });
+
+ let shouldNavigate = false;
+ try {
+ const existingSubmissions = await getChallengeSubmissions(
+ authTokens.tokenV3,
+ challengeId,
+ {
+ memberId,
+ type: submissionType,
+ },
+ );
+
+ if (!this.isComponentMounted) {
+ return;
+ }
+
+ if (existingSubmissions.data.length >= submissionLimit) {
+ fireErrorMessage(
+ 'Submission Limit Reached',
+ getSubmissionLimitReachedMessage(submissionLimit),
+ );
+ } else {
+ shouldNavigate = true;
+ }
+ } catch (error) {
+ if (!this.isComponentMounted) {
+ return;
+ }
+ fireErrorMessage(
+ 'Unable to Verify Submission Limit',
+ 'We could not verify your existing submissions. Please try again.',
+ );
+ }
+
+ this.submissionLimitCheckPending = false;
+ this.setState({ submissionLimitCheckPending: false }, () => {
+ if (shouldNavigate) {
+ history.push(`${challengesUrl}/${challengeId}/submit`);
+ }
+ });
+ };
+
buildSubmissionsArray = (source) => {
const { reviewSummationsBySubmission } = this.state;
const base = Array.isArray(source) ? source : [];
@@ -384,7 +495,7 @@ class SubmissionManagementPageContainer extends React.Component {
toBeDeletedId,
} = this.props;
- const { submissions } = this.state;
+ const { submissions, submissionLimitCheckPending } = this.state;
if (!challenge.isRegistered) return ;
@@ -446,7 +557,9 @@ class SubmissionManagementPageContainer extends React.Component {
challenge={challenge}
challengesUrl={challengesUrl}
loadingSubmissions={Boolean(loadingSubmissionsForChallengeId)}
+ onAddSubmission={this.onAddSubmission}
submissions={submissions}
+ submissionLimitCheckPending={submissionLimitCheckPending}
showDetails={showDetails}
submissionWorkflowRuns={submissionWorkflowRuns}
submissionPhaseStartDate={submissionPhaseStartDate}
@@ -553,6 +666,7 @@ SubmissionManagementPageContainer.propTypes = {
showDetails: PT.shape().isRequired,
submissionWorkflowRuns: PT.shape().isRequired,
loadAiWorkflowRuns: PT.func.isRequired,
+ history: PT.shape().isRequired,
showModal: PT.bool,
onCancelSubmissionDelete: PT.func.isRequired,
toBeDeletedId: PT.string,
diff --git a/src/shared/containers/SubmissionPage.jsx b/src/shared/containers/SubmissionPage.jsx
index 98183c11e..c122e955c 100644
--- a/src/shared/containers/SubmissionPage.jsx
+++ b/src/shared/containers/SubmissionPage.jsx
@@ -9,10 +9,12 @@
import actions from 'actions/page/submission';
import challengeDetailsActions from 'actions/page/challenge-details';
import { actions as api, errors } from 'topcoder-react-lib';
-import { isMM } from 'utils/challenge';
+import { getTrackName, isMM } from 'utils/challenge';
import {
+ getActiveSubmissionType,
getSubmissionLimit,
getSubmissionLimitReachedMessage,
+ isSubmissionLimitType,
} from 'utils/challenge-detail/submission-limit';
import communityActions from 'actions/tc-communities';
import { PrimaryButton } from 'topcoder-react-ui-kit';
@@ -33,6 +35,7 @@ const { fireErrorMessage } = errors;
export class SubmissionsPageContainer extends React.Component {
constructor(props) {
super(props);
+ this.submissionRequestPending = false;
this.handleSubmit = this.handleSubmit.bind(this);
}
@@ -48,7 +51,14 @@ export class SubmissionsPageContainer extends React.Component {
getCommunitiesList(auth);
}
- componentWillReceiveProps() {
+ componentWillReceiveProps(nextProps) {
+ const { isSubmitting } = this.props;
+ const { isSubmitting: nextIsSubmitting } = nextProps;
+
+ if (isSubmitting && !nextIsSubmitting) {
+ this.submissionRequestPending = false;
+ }
+
const {
challenge,
history,
@@ -64,14 +74,20 @@ export class SubmissionsPageContainer extends React.Component {
/**
* 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.
+ * Unlimited and non-Design challenges submit immediately. Limited Design challenges load the
+ * member's complete submission history so direct navigation cannot bypass the entry guards.
*
* @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) {
+ if (this.submissionRequestPending) {
+ return;
+ }
+
+ this.submissionRequestPending = true;
+
const {
tokenV2,
tokenV3,
@@ -80,19 +96,26 @@ export class SubmissionsPageContainer extends React.Component {
challenge,
track,
metadata,
+ phases,
userId,
} = this.props;
const submissionLimit = getSubmissionLimit(metadata);
- if (submissionLimit !== null) {
+ const submissionType = getActiveSubmissionType(phases);
+ const isDesign = getTrackName(track).toLowerCase() === 'design';
+ if (isDesign && submissionLimit !== null && isSubmissionLimitType(submissionType)) {
try {
const existingSubmissions = await getChallengeSubmissions(
tokenV3,
challengeId,
- { memberId: userId },
+ {
+ memberId: userId,
+ type: submissionType,
+ },
);
if (existingSubmissions.data.length >= submissionLimit) {
+ this.submissionRequestPending = false;
fireErrorMessage(
'Submission Limit Reached',
getSubmissionLimitReachedMessage(submissionLimit),
@@ -100,6 +123,7 @@ export class SubmissionsPageContainer extends React.Component {
return;
}
} catch (error) {
+ this.submissionRequestPending = false;
fireErrorMessage(
'Unable to Verify Submission Limit',
'We could not verify your existing submissions. Please try again.',
diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx
index 8cc7594ed..572958fd5 100644
--- a/src/shared/containers/challenge-detail/index.jsx
+++ b/src/shared/containers/challenge-detail/index.jsx
@@ -61,6 +61,13 @@ import {
import getReviewSummationsService from 'services/reviewSummations';
import { buildMmSubmissionData, buildStatisticsData } from 'utils/mm-review-summations';
import { appendUtmParamsToUrl } from 'utils/utm';
+import {
+ getActiveSubmissionType,
+ getSubmissionLimit,
+ getSubmissionLimitReachedMessage,
+ hasReachedSubmissionLimit,
+ isSubmissionLimitType,
+} from 'utils/challenge-detail/submission-limit';
// import {
// getDisplayRecommendedChallenges,
// getRecommendedTags,
@@ -291,7 +298,7 @@ function getOgImage(challenge) {
}
// The container component
-class ChallengeDetailPageContainer extends React.Component {
+export class ChallengeDetailPageContainer extends React.Component {
constructor(props, context) {
super(props, context);
@@ -315,11 +322,15 @@ class ChallengeDetailPageContainer extends React.Component {
notFoundCountryFlagUrl: {},
viewAsTable: false,
showSecurityReminder: false,
+ submissionLimitCheckPending: false,
};
+ this.submissionLimitCheckPending = false;
+
this.instanceId = shortId();
this.onToggleDeadlines = this.onToggleDeadlines.bind(this);
+ this.onSubmitChallenge = this.onSubmitChallenge.bind(this);
this.registerForChallenge = this.registerForChallenge.bind(this);
}
@@ -491,6 +502,97 @@ class ChallengeDetailPageContainer extends React.Component {
});
}
+ /**
+ * Verifies the complete active-phase submission history before opening the upload page.
+ *
+ * Unlimited, non-Design, and final-fix uploads retain the link's normal navigation. For finite
+ * Design checkpoint or contest limits, this prevents link navigation, queries every matching
+ * submission page for the member, and navigates only while a slot remains. Repeated clicks are
+ * ignored until the current check completes.
+ *
+ * @param {Object} event Submit-link click event.
+ * @return {Promise} Resolves after navigation starts or an explanatory modal is shown.
+ * @throws Does not throw; lookup failures are reported to the member.
+ */
+ async onSubmitChallenge(event) {
+ const {
+ auth,
+ challenge,
+ challengeId,
+ challengesUrl,
+ history,
+ mySubmissions,
+ } = this.props;
+ const submissionLimit = getSubmissionLimit(challenge.metadata);
+ const submissionType = getActiveSubmissionType(challenge.phases);
+ const isDesign = _.toLower(getTrackName(challenge)) === 'design';
+
+ if (!isDesign || submissionLimit === null || !isSubmissionLimitType(submissionType)) {
+ return;
+ }
+
+ if (event && event.preventDefault) {
+ event.preventDefault();
+ }
+
+ if (this.submissionLimitCheckPending) {
+ return;
+ }
+
+ if (hasReachedSubmissionLimit(challenge.metadata, mySubmissions, challenge.phases)) {
+ fireErrorMessage(
+ 'Submission Limit Reached',
+ getSubmissionLimitReachedMessage(submissionLimit),
+ );
+ return;
+ }
+
+ const memberId = _.get(auth, 'user.userId');
+ if (!auth.tokenV3 || _.isNil(memberId)) {
+ fireErrorMessage(
+ 'Unable to Verify Submission Limit',
+ 'We could not verify your existing submissions. Please try again.',
+ );
+ return;
+ }
+
+ this.submissionLimitCheckPending = true;
+ this.setState({ submissionLimitCheckPending: true });
+
+ let shouldNavigate = false;
+ try {
+ const existingSubmissions = await getChallengeSubmissionsService(
+ auth.tokenV3,
+ challengeId,
+ {
+ memberId,
+ type: submissionType,
+ },
+ );
+
+ if (existingSubmissions.data.length >= submissionLimit) {
+ fireErrorMessage(
+ 'Submission Limit Reached',
+ getSubmissionLimitReachedMessage(submissionLimit),
+ );
+ } else {
+ shouldNavigate = true;
+ }
+ } catch (error) {
+ fireErrorMessage(
+ 'Unable to Verify Submission Limit',
+ 'We could not verify your existing submissions. Please try again.',
+ );
+ }
+
+ this.submissionLimitCheckPending = false;
+ this.setState({ submissionLimitCheckPending: false }, () => {
+ if (shouldNavigate) {
+ history.push(`${challengesUrl}/${challengeId}/submit`);
+ }
+ });
+ }
+
registerForChallenge() {
const {
auth,
@@ -588,6 +690,7 @@ class ChallengeDetailPageContainer extends React.Component {
mySubmissionsSort,
viewAsTable,
showSecurityReminder,
+ submissionLimitCheckPending,
} = this.state;
const {
@@ -712,7 +815,9 @@ class ChallengeDetailPageContainer extends React.Component {
submissionEnded={submissionEnded}
mySubmissions={challenge.isRegistered ? mySubmissions : []}
mySubmissionsCount={challenge.isRegistered ? mySubmissionsCount : 0}
+ onSubmitChallenge={this.onSubmitChallenge}
openForRegistrationChallenges={openForRegistrationChallenges}
+ submissionLimitCheckPending={submissionLimitCheckPending}
viewAsTable={viewAsTable && isMM}
onSort={(currenctSelected, sort) => {
if (currenctSelected === 'submissions') {
diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js
index 545277cb6..51a8c61a2 100644
--- a/src/shared/services/submissions.js
+++ b/src/shared/services/submissions.js
@@ -126,6 +126,7 @@ async function fetchChallengeSubmissionsPage({
* @param {Number} options.perPage Number of records requested per API page.
* @param {Boolean} options.isLatest When true, fetch only the latest submission per member.
* @param {String|Number} options.memberId Optional member id used to fetch one member's history.
+ * @param {String} options.type Optional submission type used to isolate a challenge phase.
* @return {Promise<{data: Array, meta: Object}>} Aggregated submissions and
* final response metadata.
* @throws {Error} Throws when any submissions API page returns a non-2xx status.
@@ -135,6 +136,7 @@ export async function getChallengeSubmissions(tokenV3, challengeId, options = {}
isLatest,
memberId,
perPage = DEFAULT_PER_PAGE,
+ type,
} = options;
const { data, meta } = await fetchChallengeSubmissionsPage({
tokenV3,
@@ -144,6 +146,7 @@ export async function getChallengeSubmissions(tokenV3, challengeId, options = {}
filters: {
isLatest: isLatest === undefined ? undefined : isLatest,
memberId,
+ type,
},
aggregated: [],
meta: null,
diff --git a/src/shared/utils/challenge-detail/submission-limit.js b/src/shared/utils/challenge-detail/submission-limit.js
index eb27f27ea..061a9332b 100644
--- a/src/shared/utils/challenge-detail/submission-limit.js
+++ b/src/shared/utils/challenge-detail/submission-limit.js
@@ -1,4 +1,7 @@
const SUBMISSION_LIMIT_METADATA_NAME = 'submissionLimit';
+const CHECKPOINT_SUBMISSION_TYPE = 'CHECKPOINT_SUBMISSION';
+const CONTEST_SUBMISSION_TYPE = 'CONTEST_SUBMISSION';
+const FINAL_FIX_SUBMISSION_TYPE = 'STUDIO_FINAL_FIX_SUBMISSION';
/**
* Converts a metadata value to a positive integer submission limit.
@@ -96,6 +99,131 @@ export function getSubmissionLimit(metadata) {
}
}
+/**
+ * Resolves the submission type created by the currently open design phase.
+ *
+ * This mirrors the submission form's phase precedence so submission-limit checks and the
+ * eventual submission request cannot classify the same upload differently.
+ *
+ * @param {Array