From 64ed95a225e6074428d3900df13e557521761f30 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 17:27:12 +1000 Subject: [PATCH] PM-5758: enforce design submission limits end to end What was broken Checkpoint attempts consumed final-submission slots, the regular My Submissions page could navigate past a reached limit, and concurrent client checks could create duplicate attempts. Entry-point checks also relied on a partial challenge submission page. Root cause Submission-limit checks counted every submission phase together, were missing from the regular submission-management route, and started the submit pending state only after an asynchronous lookup. What was changed Centralized active submission-type handling, counted checkpoint and contest attempts independently, and bypassed concept limits for final fixes and non-Design tracks. Added complete member-and-type history checks to the challenge header, regular My Submissions route, and upload boundary, with synchronous in-flight guards and fail-closed lookup handling. Any added/updated tests Added and updated utility, service, header, challenge-detail, Submission Management, and upload tests covering phase separation, full-history checks, reached and below-limit navigation, concurrent attempts, lookup failures, final fixes, unlimited challenges, and Development challenges. --- .../SubmissionManagement.jsx | 94 ++++++++-- .../challenge-detail/Header/index.jsx | 74 +++++++- .../containers/SubmissionManagement.jsx | 169 ++++++++++++++++++ .../shared/containers/SubmissionPage.jsx | 120 ++++++++++++- .../containers/challenge-detail/index.jsx | 168 +++++++++++++++++ __tests__/shared/services/submissions.js | 5 +- .../challenge-detail/submission-limit.test.js | 74 ++++++++ .../SubmissionManagement/index.jsx | 18 +- .../SubmissionPage/Submit/index.jsx | 34 +--- .../challenge-detail/Header/index.jsx | 23 ++- .../containers/SubmissionManagement/index.jsx | 120 ++++++++++++- src/shared/containers/SubmissionPage.jsx | 36 +++- .../containers/challenge-detail/index.jsx | 107 ++++++++++- src/shared/services/submissions.js | 3 + .../challenge-detail/submission-limit.js | 128 +++++++++++++ 15 files changed, 1097 insertions(+), 76 deletions(-) diff --git a/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx b/__tests__/shared/components/SubmissionManagement/SubmissionManagement.jsx index 097d670927..cf5ce276f9 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 5270b4962f..24c2b9a279 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 2758d558fd..7e8bfc35d2 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 9d8541e1bc..9935dbc9fa 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 59b7497c43..d93801f653 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 bacffb1dba..e771b4fa8d 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 80281bf86d..20092d1345 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 c0f962fe35..2eaa362d7e 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 330c7165a8..99c3fd6be0 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 9f6786431a..ede52e88db 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 047637e430..13f19e5b6d 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 98183c11ee..c122e955cb 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 8cc7594ed7..572958fd5d 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 545277cb6d..51a8c61a29 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 eb27f27ea5..061a9332ba 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} phases Challenge phases. + * @return {String} V6 submission type for the active submission phase. + * @throws Does not throw; challenges without an open submission phase use contest submissions. + */ +export function getActiveSubmissionType(phases) { + const challengePhases = Array.isArray(phases) ? phases : []; + const checkpoint = challengePhases.find(phase => ( + phase && phase.name === 'Checkpoint Submission' + )); + const submission = challengePhases.find(phase => ( + phase && phase.name === 'Submission' + )); + const finalFix = challengePhases.find(phase => ( + phase && phase.name === 'Final Fix' + )); + + if (checkpoint && checkpoint.isOpen) { + return CHECKPOINT_SUBMISSION_TYPE; + } + + if (checkpoint && !checkpoint.isOpen && submission && submission.isOpen) { + return CONTEST_SUBMISSION_TYPE; + } + + if (finalFix && finalFix.isOpen) { + return FINAL_FIX_SUBMISSION_TYPE; + } + + return CONTEST_SUBMISSION_TYPE; +} + +/** + * Converts current and legacy submission type values to the V6 API representation. + * + * @param {*} value Raw `type` or legacy `submissionType` value. + * @return {String} Normalized submission type, or an empty string when unavailable. + * @throws Does not throw. + */ +function normalizeSubmissionType(value) { + const normalizedValue = String(value || '') + .trim() + .toUpperCase() + .replace(/[\s-]+/g, '_'); + + if (normalizedValue === 'CHECKPOINT') { + return CHECKPOINT_SUBMISSION_TYPE; + } + + if (normalizedValue === 'CONTEST' || normalizedValue === 'SUBMISSION') { + return CONTEST_SUBMISSION_TYPE; + } + + if (normalizedValue === 'FINAL_FIX' || normalizedValue === 'STUDIO_FINAL_FIX') { + return FINAL_FIX_SUBMISSION_TYPE; + } + + return normalizedValue; +} + +/** + * Checks whether concept submission limits apply to a V6 submission type. + * + * Checkpoint and contest submissions are limited independently. Final-fix submissions belong to + * winning-submission fulfillment and must remain available regardless of the concept limit. + * + * @param {String} submissionType V6 submission type. + * @return {Boolean} Whether submission-limit metadata applies to the type. + * @throws Does not throw. + */ +export function isSubmissionLimitType(submissionType) { + return submissionType === CHECKPOINT_SUBMISSION_TYPE + || submissionType === CONTEST_SUBMISSION_TYPE; +} + +/** + * Counts submissions belonging to the currently active submission phase. + * + * Checkpoint and contest submissions have independent limits. Final fixes are excluded. Both the + * current V6 `type` property and the legacy `submissionType` property are supported. + * + * @param {Array} submissions Member submissions for the challenge. + * @param {Array} phases Challenge phases. + * @return {Number} Number of submissions matching the active phase type. + * @throws Does not throw; invalid submission collections count as empty. + */ +export function getActiveSubmissionCount(submissions, phases) { + if (!Array.isArray(submissions)) { + return 0; + } + + const activeSubmissionType = getActiveSubmissionType(phases); + + if (!isSubmissionLimitType(activeSubmissionType)) { + return 0; + } + + return submissions.filter(submission => ( + submission + && normalizeSubmissionType(submission.type || submission.submissionType) + === activeSubmissionType + )).length; +} + +/** + * Checks whether the member has reached the configured limit for the active submission phase. + * + * @param {Array} metadata Challenge metadata entries. + * @param {Array} submissions Member submissions for the challenge. + * @param {Array} phases Challenge phases. + * @return {Boolean} Whether the active phase has no submission slots remaining. + * @throws Does not throw; missing or invalid limits are treated as unlimited. + */ +export function hasReachedSubmissionLimit(metadata, submissions, phases) { + const submissionLimit = getSubmissionLimit(metadata); + + return submissionLimit !== null + && getActiveSubmissionCount(submissions, phases) >= submissionLimit; +} + /** * Builds the message shown when a member has no remaining submission slots. *