From c387bbe43d0cf64867edf8511630a16845c29fd1 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 7 Aug 2023 17:23:23 +0200 Subject: [PATCH] feat: disable submission until it's done (#51150) --- .../components/completion-modal.tsx | 15 ++++++++--- .../Challenges/redux/action-types.js | 6 ++--- .../src/templates/Challenges/redux/actions.js | 6 +++++ .../Challenges/redux/completion-epic.js | 9 ++++--- .../src/templates/Challenges/redux/index.js | 15 ++++++++++- .../templates/Challenges/redux/selectors.js | 1 + .../e2e/default/learn/challenges/projects.ts | 27 +++++++++++++++++++ 7 files changed, 68 insertions(+), 11 deletions(-) diff --git a/client/src/templates/Challenges/components/completion-modal.tsx b/client/src/templates/Challenges/components/completion-modal.tsx index 5d9beabf6e0..7e1099eeddd 100644 --- a/client/src/templates/Challenges/components/completion-modal.tsx +++ b/client/src/templates/Challenges/components/completion-modal.tsx @@ -21,7 +21,8 @@ import { isCompletionModalOpenSelector, successMessageSelector, challengeFilesSelector, - challengeMetaSelector + challengeMetaSelector, + isSubmittingSelector } from '../redux/selectors'; import ProgressBar from '../../../components/ProgressBar'; import GreenPass from '../../../assets/icons/green-pass'; @@ -36,7 +37,7 @@ const mapStateToProps = createSelector( isSignedInSelector, allChallengesInfoSelector, successMessageSelector, - + isSubmittingSelector, ( challengeFiles: ChallengeFiles, { dashedName }: { dashedName: string }, @@ -44,7 +45,8 @@ const mapStateToProps = createSelector( isOpen: boolean, isSignedIn: boolean, allChallengesInfo: AllChallengesInfo, - message: string + message: string, + isSubmitting: boolean ) => ({ challengeFiles, dashedName, @@ -52,7 +54,8 @@ const mapStateToProps = createSelector( isOpen, isSignedIn, allChallengesInfo, - message + message, + isSubmitting }) ); @@ -147,6 +150,7 @@ class CompletionModal extends Component< close, isOpen, isSignedIn, + isSubmitting, message, t, dashedName, @@ -159,6 +163,7 @@ class CompletionModal extends Component< return ( submitChallenge()} > {isSignedIn ? t('buttons.submit-and-go') : t('buttons.go-to-next')} diff --git a/client/src/templates/Challenges/redux/action-types.js b/client/src/templates/Challenges/redux/action-types.js index 156ce44f77a..6ffbb0ab6ab 100644 --- a/client/src/templates/Challenges/redux/action-types.js +++ b/client/src/templates/Challenges/redux/action-types.js @@ -1,4 +1,4 @@ -import { createTypes } from '../../../utils/create-types'; +import { createAsyncTypes, createTypes } from '../../../utils/create-types'; export const CURRENT_CHALLENGE_KEY = 'currentChallengeId'; @@ -43,10 +43,10 @@ export const actionTypes = createTypes( 'executeChallenge', 'resetChallenge', 'stopResetting', - 'submitChallenge', 'resetAttempts', 'setEditorFocusability', - 'toggleVisibleEditor' + 'toggleVisibleEditor', + ...createAsyncTypes('submitChallenge') ], ns ); diff --git a/client/src/templates/Challenges/redux/actions.js b/client/src/templates/Challenges/redux/actions.js index 7fcedc014bf..f5796c2ed9c 100644 --- a/client/src/templates/Challenges/redux/actions.js +++ b/client/src/templates/Challenges/redux/actions.js @@ -71,6 +71,12 @@ export const executeChallenge = createAction(actionTypes.executeChallenge); export const resetChallenge = createAction(actionTypes.resetChallenge); export const stopResetting = createAction(actionTypes.stopResetting); export const submitChallenge = createAction(actionTypes.submitChallenge); +export const submitChallengeComplete = createAction( + actionTypes.submitChallengeComplete +); +export const submitChallengeError = createAction( + actionTypes.submitChallengeError +); export const resetAttempts = createAction(actionTypes.resetAttempts); export const setEditorFocusability = createAction( diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index a01a7f6e99c..9d8c70ab8fa 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -36,7 +36,9 @@ import { actionTypes } from './action-types'; import { closeModal, updateSolutionFormValues, - setIsAdvancing + setIsAdvancing, + submitChallengeComplete, + submitChallengeError } from './actions'; import { challengeFilesSelector, @@ -73,14 +75,15 @@ function postChallenge(update, username) { }, savedChallenges: mapFilesToChallengeFiles(savedChallenges) }), - updateComplete() + updateComplete(), + submitChallengeComplete() ]; // TODO(Post-MVP): separate endpoint for trophy submission? if (isTrophyMissing) actions.push(createFlashMessage(trophyMissingMessage)); return of(...actions); }), - catchError(() => of(updateFailed(update))) + catchError(() => of(updateFailed(update), submitChallengeError())) ); return saveChallenge; } diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index 713e32e5a9b..8d4f058b936 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -52,7 +52,8 @@ const initialState = { projectFormValues: {}, successMessage: 'Happy Coding!', isAdvancing: false, - chapterSlug: '' + chapterSlug: '', + isSubmitting: false }; export const epics = [completionEpic, createQuestionEpic, codeStorageEpic]; @@ -64,6 +65,18 @@ export const sagas = [ export const reducer = handleActions( { + [actionTypes.submitChallenge]: state => ({ + ...state, + isSubmitting: true + }), + [actionTypes.submitChallengeComplete]: state => ({ + ...state, + isSubmitting: false + }), + [actionTypes.submitChallengeError]: state => ({ + ...state, + isSubmitting: false + }), [actionTypes.createFiles]: (state, { payload }) => ({ ...state, challengeFiles: payload, diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js index 0e27b560dc1..fc389b8712b 100644 --- a/client/src/templates/Challenges/redux/selectors.js +++ b/client/src/templates/Challenges/redux/selectors.js @@ -33,6 +33,7 @@ export const isFinishExamModalOpenSelector = state => export const isProjectPreviewModalOpenSelector = state => state[ns].modal.projectPreview; export const isShortcutsModalOpenSelector = state => state[ns].modal.shortcuts; +export const isSubmittingSelector = state => state[ns].isSubmitting; export const isResettingSelector = state => state[ns].isResetting; export const isBuildEnabledSelector = state => state[ns].isBuildEnabled; diff --git a/cypress/e2e/default/learn/challenges/projects.ts b/cypress/e2e/default/learn/challenges/projects.ts index d927e8369b7..4a7c1863286 100644 --- a/cypress/e2e/default/learn/challenges/projects.ts +++ b/cypress/e2e/default/learn/challenges/projects.ts @@ -221,4 +221,31 @@ describe('project submission', () => { ); } ); + + it('should not be possible to submit twice in quick succession', () => { + const { superBlock, block, challenges } = pythonProjects; + const { slug } = challenges[0]; + + cy.intercept('http://localhost:3000/project-completed', req => { + req.continue(_res => { + // delay the response by 0.5 seconds + const wait = new Promise(resolve => setTimeout(resolve, 500)); + return wait; + }); + }); + + const url = `/learn/${superBlock}/${block}/${slug}`; + cy.visit(url); + cy.get('#dynamic-front-end-form') + .get('#solution') + .type('https://replit.com/@camperbot/python-project#main.py'); + + cy.contains("I've completed this challenge").click(); + cy.get('[data-cy=submit-challenge]').as('submitChallenge'); + cy.get('@submitChallenge').click(); + cy.get('@submitChallenge').should('be.disabled'); + // After the api responds, the button is enabled, but since the modal leaves + // the DOM we just check for that. + cy.get('[data-cy=completion-modal]').should('not.exist'); + }); });