feat(client): move the block donation modal logic to an epic. (#49381)

feat(client): move percent calculation logic to epic
This commit is contained in:
Ahmad Abdolsaheb
2023-02-19 10:04:04 +03:00
committed by GitHub
parent 3fc306bc2b
commit 13aad8ca37
6 changed files with 207 additions and 173 deletions

View File

@@ -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 = (
<div className=' text-center block-modal-text'>
<div className='donation-icon-container'>
<Cup className='donation-icon' />
</div>
<Row>
{!closeLabel && (
<Col sm={10} smOffset={1} xs={12}>
<b>{t('donate.nicely-done', { block: recentlyClaimedBlock })}</b>
<br />
{getDonationText()}
</Col>
{recentlyClaimedBlock !== null ? (
<Cup className='donation-icon' />
) : (
<Heart className='donation-icon' />
)}
</Row>
</div>
);
const progressDonationText = (
<div className='text-center progress-modal-text'>
<div className='donation-icon-container'>
<Heart className='donation-icon' />
</div>
<Row>
{!closeLabel && (
<Col sm={10} smOffset={1} xs={12}>
{getDonationText()}
{recentlyClaimedBlock !== null && (
<b>
{t('donate.nicely-done', {
block: t(
`intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title`
)
})}
</b>
)}
{getCommonDonationText()}
</Col>
)}
</Row>
@@ -135,7 +133,7 @@ function DonateModal({
show={show}
>
<Modal.Body>
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
{donationText}
<Spacer />
<Row>
<Col xs={12}>

View File

@@ -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('<CompletionModal />', () => {
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('<CompletionModal />', () => {
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);
});
});

View File

@@ -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<CompletionModalsProps>,
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')}
<span className='hidden-xs'> (Ctrl + Enter)</span>
@@ -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 <CompletionModalInner currentBlockIds={currentBlockIds} {...props} />;
};
CompletionModal.displayName = 'CompletionModal';
export default connect(

View File

@@ -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());

View File

@@ -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;

View File

@@ -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;
};