diff --git a/client/src/components/Donation/donation-modal.tsx b/client/src/components/Donation/donation-modal.tsx index 04d5f2e4efb..f87e0bb17ad 100644 --- a/client/src/components/Donation/donation-modal.tsx +++ b/client/src/components/Donation/donation-modal.tsx @@ -23,10 +23,12 @@ import { playTone } from '../../utils/tone'; import { Spacer } from '../helpers'; import DonateForm from './donate-form'; +type RecentlyClaimedBlock = null | { block: string; superBlock: string }; + const mapStateToProps = createSelector( isDonationModalOpenSelector, recentlyClaimedBlockSelector, - (show: boolean, recentlyClaimedBlock: string) => ({ + (show: boolean, recentlyClaimedBlock: RecentlyClaimedBlock) => ({ show, recentlyClaimedBlock }) @@ -46,7 +48,7 @@ type DonateModalProps = { closeDonationModal: typeof closeDonationModal; executeGA: typeof executeGA; location?: WindowLocation; - recentlyClaimedBlock: string; + recentlyClaimedBlock: RecentlyClaimedBlock; show: boolean; }; @@ -70,13 +72,13 @@ function DonateModal({ executeGA({ event: 'donationview', action: `Displayed ${ - recentlyClaimedBlock ? 'Block' : 'Progress' + recentlyClaimedBlock !== null ? 'Block' : 'Progress' } Donation Modal` }); } }, [show, recentlyClaimedBlock, executeGA]); - const getDonationText = () => { + const getCommonDonationText = () => { const donationDuration = modalDefaultDonation.donationDuration; switch (donationDuration) { case 'one-time': @@ -95,32 +97,28 @@ function DonateModal({ } }; - const blockDonationText = ( + const donationText = (
- -
- - {!closeLabel && ( - - {t('donate.nicely-done', { block: recentlyClaimedBlock })} -
- {getDonationText()} - + {recentlyClaimedBlock !== null ? ( + + ) : ( + )} -
-
- ); - - const progressDonationText = ( -
-
-
{!closeLabel && ( - {getDonationText()} + {recentlyClaimedBlock !== null && ( + + {t('donate.nicely-done', { + block: t( + `intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title` + ) + })} + + )} + {getCommonDonationText()} )} @@ -135,7 +133,7 @@ function DonateModal({ show={show} > - {recentlyClaimedBlock ? blockDonationText : progressDonationText} + {donationText} diff --git a/client/src/templates/Challenges/components/completion-modal.test.tsx b/client/src/templates/Challenges/components/completion-modal.test.tsx index 19aeb5efdcc..7d151bd0588 100644 --- a/client/src/templates/Challenges/components/completion-modal.test.tsx +++ b/client/src/templates/Challenges/components/completion-modal.test.tsx @@ -1,4 +1,4 @@ -import { getCompletedPercent } from './completion-modal'; +import { getCompletedPercentage } from '../../../utils/get-completion-percentage'; jest.mock('../../../analytics'); @@ -8,16 +8,16 @@ const completedChallengesIds = ['1', '3', '5'], fakeCompletedChallengesIds = ['1', '3', '5', '7', '8']; describe('', () => { - describe('getCompletedPercent', () => { + describe('getCompletedPercentage', () => { it('returns 25 if one out of four challenges are complete', () => { - expect(getCompletedPercent([], currentBlockIds, currentBlockIds[1])).toBe( - 25 - ); + expect( + getCompletedPercentage([], currentBlockIds, currentBlockIds[1]) + ).toBe(25); }); it('returns 75 if three out of four challenges are complete', () => { expect( - getCompletedPercent( + getCompletedPercentage( completedChallengesIds, currentBlockIds, completedChallengesIds[0] @@ -27,13 +27,13 @@ describe('', () => { it('returns 100 if all challenges have been completed', () => { expect( - getCompletedPercent(completedChallengesIds, currentBlockIds, id) + getCompletedPercentage(completedChallengesIds, currentBlockIds, id) ).toBe(100); }); it('returns 100 if more challenges have been complete than exist', () => { expect( - getCompletedPercent(fakeCompletedChallengesIds, currentBlockIds, id) + getCompletedPercentage(fakeCompletedChallengesIds, currentBlockIds, id) ).toBe(100); }); }); diff --git a/client/src/templates/Challenges/components/completion-modal.tsx b/client/src/templates/Challenges/components/completion-modal.tsx index de8c7076b8c..dcf5ae24650 100644 --- a/client/src/templates/Challenges/components/completion-modal.tsx +++ b/client/src/templates/Challenges/components/completion-modal.tsx @@ -9,22 +9,23 @@ import { Dispatch } from 'redux'; import { createSelector } from 'reselect'; import { dasherize } from '../../../../../utils/slugs'; -import { isFinalProject } from '../../../../utils/challenge-types'; import Login from '../../../components/Header/components/Login'; -import { executeGA, allowBlockDonationRequests } from '../../../redux/actions'; +import { executeGA } from '../../../redux/actions'; import { isSignedInSelector, allChallengesInfoSelector } from '../../../redux/selectors'; import { AllChallengesInfo, ChallengeFiles } from '../../../redux/prop-types'; - import { closeModal, submitChallenge } from '../redux/actions'; import { - completedChallengesIds, + completedChallengesIdsSelector, isCompletionModalOpenSelector, successMessageSelector, challengeFilesSelector, - challengeMetaSelector + challengeMetaSelector, + completedPercentageSelector, + completedChallengesInBlockSelector, + currentBlockIdsSelector } from '../redux/selectors'; import CompletionModalBody from './completion-modal-body'; @@ -33,11 +34,14 @@ import './completion-modal.css'; const mapStateToProps = createSelector( challengeFilesSelector, challengeMetaSelector, - completedChallengesIds, + completedChallengesIdsSelector, isCompletionModalOpenSelector, isSignedInSelector, allChallengesInfoSelector, successMessageSelector, + completedPercentageSelector, + completedChallengesInBlockSelector, + currentBlockIdsSelector, ( challengeFiles: ChallengeFiles, { @@ -49,7 +53,10 @@ const mapStateToProps = createSelector( isOpen: boolean, isSignedIn: boolean, allChallengesInfo: AllChallengesInfo, - message: string + message: string, + completedPercent: number, + completedChallengesInBlock: number, + currentBlockIds: string[] ) => ({ challengeFiles, title, @@ -59,7 +66,10 @@ const mapStateToProps = createSelector( isOpen, isSignedIn, allChallengesInfo, - message + message, + completedPercent, + completedChallengesInBlock, + currentBlockIds }) ); @@ -69,55 +79,18 @@ const mapDispatchToProps = function (dispatch: Dispatch) { submitChallenge: () => { dispatch(submitChallenge()); }, - allowBlockDonationRequests: (block: string) => { - dispatch(allowBlockDonationRequests(block)); - }, executeGA }; return () => dispatchers; }; -export function getCompletedPercent( - completedChallengesIds: string[] = [], - currentBlockIds: string[] = [], - currentChallengeId: string -): number { - const completedChallengesInBlock = getCompletedChallengesInBlock( - completedChallengesIds, - currentBlockIds, - currentChallengeId - ); - const completedPercent = Math.round( - (completedChallengesInBlock / currentBlockIds.length) * 100 - ); - - return completedPercent > 100 ? 100 : completedPercent; -} - -function getCompletedChallengesInBlock( - completedChallengesIds: string[], - currentBlockChallengeIds: string[], - currentChallengeId: string -) { - const oldCompletionCount = completedChallengesIds.filter(challengeId => - currentBlockChallengeIds.includes(challengeId) - ).length; - - const isAlreadyCompleted = - completedChallengesIds.includes(currentChallengeId); - - return isAlreadyCompleted ? oldCompletionCount : oldCompletionCount + 1; -} - interface CompletionModalsProps { - allowBlockDonationRequests: (arg0: string) => void; block: string; blockName: string; certification: string; challengeType: number; close: () => void; completedChallengesIds: string[]; - currentBlockIds?: string[]; executeGA: () => void; challengeFiles: ChallengeFiles; id: string; @@ -125,44 +98,40 @@ interface CompletionModalsProps { isSignedIn: boolean; allChallengesInfo: AllChallengesInfo; message: string; + completedPercent: number; + completedChallengesInBlock: number; + currentBlockIds: string[]; submitChallenge: () => void; superBlock: string; t: TFunction; title: string; } -interface CompletionModalInnerState { +interface CompletionModalState { downloadURL: null | string; - completedPercent: number; - completedChallengesInBlock: number; } -class CompletionModalInner extends Component< +class CompletionModal extends Component< CompletionModalsProps, - CompletionModalInnerState + CompletionModalState > { + static displayName: string; constructor(props: CompletionModalsProps) { super(props); - this.handleSubmit = this.handleSubmit.bind(this); this.handleKeypress = this.handleKeypress.bind(this); - this.state = { - downloadURL: null, - completedPercent: 0, - completedChallengesInBlock: 0 + downloadURL: null }; } static getDerivedStateFromProps( - props: CompletionModalsProps, - state: CompletionModalInnerState - ): CompletionModalInnerState { + props: Readonly, + state: CompletionModalState + ): CompletionModalState { const { challengeFiles, isOpen } = props; if (!isOpen) { return { - downloadURL: null, - completedPercent: 0, - completedChallengesInBlock: 0 + downloadURL: null }; } const { downloadURL } = state; @@ -187,25 +156,8 @@ class CompletionModalInner extends Component< }); newURL = URL.createObjectURL(blob); } - - const { completedChallengesIds, currentBlockIds, id, isSignedIn } = props; - const completedPercent = isSignedIn - ? getCompletedPercent(completedChallengesIds, currentBlockIds, id) - : 0; - - let completedChallengesInBlock = 0; - if (currentBlockIds) { - completedChallengesInBlock = getCompletedChallengesInBlock( - completedChallengesIds, - currentBlockIds, - id - ); - } - return { - downloadURL: newURL, - completedPercent, - completedChallengesInBlock + downloadURL: newURL }; } @@ -215,22 +167,7 @@ class CompletionModalInner extends Component< // Since Hotkeys also listens to Ctrl + Enter we have to stop this event // getting to it. e.stopPropagation(); - this.handleSubmit(); - } - } - - handleSubmit(): void { - this.props.submitChallenge(); - this.checkBlockCompletion(); - } - - // check block completion for donation - checkBlockCompletion(): void { - if ( - this.state.completedPercent === 100 && - !this.props.completedChallengesIds.includes(this.props.id) - ) { - this.props.allowBlockDonationRequests(this.props.blockName); + this.props.submitChallenge(); } } @@ -245,19 +182,20 @@ class CompletionModalInner extends Component< const { block, close, - currentBlockIds, id, isOpen, isSignedIn, message, superBlock = '', t, - title + title, + completedPercent, + completedChallengesInBlock, + currentBlockIds, + submitChallenge } = this.props; - const { completedPercent, completedChallengesInBlock } = this.state; - - const totalChallengesInBlock = currentBlockIds?.length ?? 0; + const totalChallengesInBlock = currentBlockIds.length; if (isOpen) { executeGA({ event: 'pageview', pagePath: '/completion-modal' }); @@ -303,7 +241,7 @@ class CompletionModalInner extends Component< block={true} bsSize='large' bsStyle='primary' - onClick={() => this.handleSubmit()} + onClick={() => submitChallenge()} > {isSignedIn ? t('buttons.submit-and-go') : t('buttons.go-to-next')} (Ctrl + Enter) @@ -326,40 +264,6 @@ class CompletionModalInner extends Component< } } -interface Options { - isFinalProjectBlock: boolean; -} - -const useCurrentBlockIds = ( - allChallengesInfo: AllChallengesInfo, - block: string, - certification: string, - options?: Options -) => { - const { challengeEdges, certificateNodes } = allChallengesInfo; - const currentCertificateIds = certificateNodes - .filter( - node => dasherize(node.challenge.certification) === certification - )[0] - ?.challenge.tests.map(test => test.id); - const currentBlockIds = challengeEdges - .filter(edge => edge.node.challenge.block === block) - .map(edge => edge.node.challenge.id); - - return options?.isFinalProjectBlock ? currentCertificateIds : currentBlockIds; -}; - -const CompletionModal = (props: CompletionModalsProps) => { - const currentBlockIds = useCurrentBlockIds( - props.allChallengesInfo, - props.block || '', - props.certification || '', - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - { isFinalProjectBlock: isFinalProject(props.challengeType) } - ); - return ; -}; - CompletionModal.displayName = 'CompletionModal'; export default connect( diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index fb01406a690..c4dc53c0517 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -2,11 +2,19 @@ import { navigate } from 'gatsby'; import { omit } from 'lodash-es'; import { ofType } from 'redux-observable'; import { empty, of } from 'rxjs'; -import { catchError, concat, retry, switchMap, tap } from 'rxjs/operators'; +import { + catchError, + concat, + retry, + switchMap, + tap, + mergeMap +} from 'rxjs/operators'; import { isChallenge } from '../../../utils/path-parsers'; import { challengeTypes, submitTypes } from '../../../../utils/challenge-types'; import { actionTypes as submitActionTypes } from '../../../redux/action-types'; import { + allowBlockDonationRequests, submitComplete, updateComplete, updateFailed @@ -25,7 +33,8 @@ import { challengeFilesSelector, challengeMetaSelector, challengeTestsSelector, - projectFormValuesSelector + projectFormValuesSelector, + isBlockNewlyCompletedSelector } from './selectors'; function postChallenge(update, username) { @@ -156,8 +165,9 @@ export default function completionEpic(action$, state$) { ofType(actionTypes.submitChallenge), switchMap(({ type }) => { const state = state$.value; - const meta = challengeMetaSelector(state); - const { nextChallengePath, challengeType, superBlock } = meta; + + const { nextChallengePath, challengeType, superBlock, block } = + challengeMetaSelector(state); let submitter = () => of({ type: 'no-user-signed-in' }); if ( @@ -169,6 +179,7 @@ export default function completionEpic(action$, state$) { challengeType ); } + if (isSignedInSelector(state)) { submitter = submitters[submitTypes[challengeType]]; } @@ -177,8 +188,17 @@ export default function completionEpic(action$, state$) { return findPathToNavigateTo(nextChallengePath, superBlock); }; + const canAllowDonationRequest = (state, action) => + isBlockNewlyCompletedSelector(state) && + action.type === submitActionTypes.submitComplete; + return submitter(type, state).pipe( concat(of(setIsAdvancing(isChallenge(pathToNavigateTo())))), + mergeMap(x => + canAllowDonationRequest(state, x) + ? of(x, allowBlockDonationRequests({ superBlock, block })) + : of(x) + ), tap(res => { if (res.type !== submitActionTypes.updateFailed) { navigate(pathToNavigateTo()); diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js index 251aca75638..8515685af8e 100644 --- a/client/src/templates/Challenges/redux/selectors.js +++ b/client/src/templates/Challenges/redux/selectors.js @@ -1,12 +1,21 @@ import { challengeTypes } from '../../../../utils/challenge-types'; -import { completedChallengesSelector } from '../../../redux/selectors'; +import { + completedChallengesSelector, + allChallengesInfoSelector, + isSignedInSelector +} from '../../../redux/selectors'; +import { + getCurrentBlockIds, + getCompletedChallengesInBlock, + getCompletedPercentage +} from '../../../utils/get-completion-percentage'; import { ns } from './action-types'; export const challengeFilesSelector = state => state[ns].challengeFiles; export const challengeMetaSelector = state => state[ns].challengeMeta; export const challengeTestsSelector = state => state[ns].challengeTests; export const consoleOutputSelector = state => state[ns].consoleOut; -export const completedChallengesIds = state => +export const completedChallengesIdsSelector = state => completedChallengesSelector(state).map(node => node.id); export const isChallengeCompletedSelector = state => { const completedChallenges = completedChallengesSelector(state); @@ -82,6 +91,53 @@ export const challengeDataSelector = state => { return challengeData; }; +export const currentBlockIdsSelector = state => { + const { block, certification, challengeType } = challengeMetaSelector(state); + const allChallengesInfo = allChallengesInfoSelector(state); + const currentBlockIds = getCurrentBlockIds( + allChallengesInfo, + block, + certification, + challengeType + ); + + return currentBlockIds; +}; + +export const completedChallengesInBlockSelector = state => { + const completedChallengesIds = completedChallengesIdsSelector(state); + const currentBlockIds = currentBlockIdsSelector(state); + const { id } = challengeMetaSelector(state); + const completedChallengesInBlock = getCompletedChallengesInBlock( + completedChallengesIds, + currentBlockIds, + id + ); + return completedChallengesInBlock; +}; + +export const completedPercentageSelector = state => { + const isSignedIn = isSignedInSelector(state); + if (isSignedIn) { + const completedChallengesIds = completedChallengesIdsSelector(state); + const { id } = challengeMetaSelector(state); + const currentBlockIds = currentBlockIdsSelector(state); + const completedPercentage = getCompletedPercentage( + completedChallengesIds, + currentBlockIds, + id + ); + return completedPercentage; + } else return 0; +}; + +export const isBlockNewlyCompletedSelector = state => { + const completedPercentage = completedPercentageSelector(state); + const completedChallengesIds = completedChallengesIdsSelector(state); + const { id } = challengeMetaSelector(state); + return completedPercentage === 100 && !completedChallengesIds.includes(id); +}; + export const attemptsSelector = state => state[ns].attempts; export const canFocusEditorSelector = state => state[ns].canFocusEditor; export const visibleEditorsSelector = state => state[ns].visibleEditors; diff --git a/client/src/utils/get-completion-percentage.ts b/client/src/utils/get-completion-percentage.ts new file mode 100644 index 00000000000..1371604d2d4 --- /dev/null +++ b/client/src/utils/get-completion-percentage.ts @@ -0,0 +1,56 @@ +import { AllChallengesInfo } from '../redux/prop-types'; +import { dasherize } from '../../../utils/slugs'; +import { isFinalProject } from '../../utils/challenge-types'; + +export function getCompletedPercentage( + completedChallengesIds: string[] = [], + currentBlockIds: string[] = [], + currentChallengeId: string +): number { + const completedChallengesInBlock = getCompletedChallengesInBlock( + completedChallengesIds, + currentBlockIds, + currentChallengeId + ); + const completedPercent = Math.round( + (completedChallengesInBlock / currentBlockIds.length) * 100 + ); + + return completedPercent > 100 ? 100 : completedPercent; +} + +export function getCompletedChallengesInBlock( + completedChallengesIds: string[], + currentBlockIds: string[], + currentChallengeId: string +) { + const oldCompletionCount = completedChallengesIds.filter(challengeId => + currentBlockIds.includes(challengeId) + ).length; + + const isAlreadyCompleted = + completedChallengesIds.includes(currentChallengeId); + + return isAlreadyCompleted ? oldCompletionCount : oldCompletionCount + 1; +} + +export const getCurrentBlockIds = ( + allChallengesInfo: AllChallengesInfo, + block: string, + certification: string, + challengeType: number +) => { + const { challengeEdges, certificateNodes } = allChallengesInfo; + const currentCertificateIds = certificateNodes + .filter( + node => dasherize(node.challenge.certification) === certification + )[0] + ?.challenge.tests.map(test => test.id); + const currentBlockIds = challengeEdges + .filter(edge => edge.node.challenge.block === block) + .map(edge => edge.node.challenge.id); + + return isFinalProject(challengeType) + ? currentCertificateIds + : currentBlockIds; +};