From 369368a79974221dd8c287c6408a56be9388c800 Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Tue, 7 Nov 2023 09:04:12 -0600 Subject: [PATCH] feat(client/api): add C# survey (#51682) --- api-server/src/common/models/user.json | 5 + api-server/src/common/utils/index.js | 2 + api-server/src/server/boot/user.js | 72 ++++++- api-server/src/server/middlewares/survey.js | 41 ++++ api-server/src/server/model-config.json | 4 + api-server/src/server/models/survey.json | 41 ++++ .../src/server/utils/publicUserProps.js | 1 + client/i18n/locales/english/translations.json | 32 +++ .../components/Flash/redux/flash-messages.ts | 4 + client/src/redux/action-types.js | 2 + client/src/redux/actions.js | 5 + client/src/redux/index.js | 21 +- client/src/redux/prop-types.ts | 12 ++ client/src/redux/selectors.js | 3 + client/src/redux/survey-saga.js | 40 ++++ .../foundational-c-sharp-survey-alert.tsx | 52 +++++ .../foundational-c-sharp-survey.tsx | 198 ++++++++++++++++++ .../exam/components/missing-prerequisites.tsx | 31 +++ .../exam/components/survey-modal.tsx | 55 +++++ client/src/templates/Challenges/exam/show.tsx | 61 +++--- .../src/templates/Challenges/redux/index.js | 1 + .../templates/Challenges/redux/selectors.js | 1 + client/src/utils/ajax.ts | 7 + client/src/utils/tone/index.ts | 4 + cypress.config.js | 17 +- .../default/learn/challenges/c-sharp-exam.ts | 109 ++++++++++ package.json | 5 +- tools/scripts/seed-exams/create-exams.js | 2 +- tools/scripts/seed/seed-demo-user.js | 44 +++- tools/scripts/seed/seed-surveys.js | 99 +++++++++ 30 files changed, 939 insertions(+), 32 deletions(-) create mode 100644 api-server/src/server/middlewares/survey.js create mode 100644 api-server/src/server/models/survey.json create mode 100644 client/src/redux/survey-saga.js create mode 100644 client/src/templates/Challenges/exam/components/foundational-c-sharp-survey-alert.tsx create mode 100644 client/src/templates/Challenges/exam/components/foundational-c-sharp-survey.tsx create mode 100644 client/src/templates/Challenges/exam/components/missing-prerequisites.tsx create mode 100644 client/src/templates/Challenges/exam/components/survey-modal.tsx create mode 100644 cypress/e2e/default/learn/challenges/c-sharp-exam.ts create mode 100644 tools/scripts/seed/seed-surveys.js diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index a48b84eff75..ba22b7f689b 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -428,6 +428,11 @@ "type": "hasMany", "model": "MsUsername", "foreignKey": "userId" + }, + "surveys": { + "type": "hasMany", + "model": "Survey", + "foreignKey": "userId" } }, "acls": [ diff --git a/api-server/src/common/utils/index.js b/api-server/src/common/utils/index.js index 6c03879dc51..cfc64be9267 100644 --- a/api-server/src/common/utils/index.js +++ b/api-server/src/common/utils/index.js @@ -30,3 +30,5 @@ export const fixPartiallyCompletedChallengeItem = obj => export const fixCompletedExamItem = obj => pick(obj, ['id', 'completedDate', 'challengeType', 'examResults']); + +export const fixCompletedSurveyItem = obj => pick(obj, ['title', 'responses']); diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index e539911ea7a..17a46a89256 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -7,6 +7,7 @@ import fetch from 'node-fetch'; import { fixCompletedChallengeItem, fixCompletedExamItem, + fixCompletedSurveyItem, fixPartiallyCompletedChallengeItem, fixSavedChallengeItem } from '../../common/utils'; @@ -24,6 +25,7 @@ import { encodeUserToken } from '../middlewares/user-token'; import { createDeleteMsUsername } from '../middlewares/ms-username'; +import { validateSurvey, createDeleteUserSurveys } from '../middlewares/survey'; import { deprecatedEndpoint } from '../utils/disabled-endpoints'; const log = debugFactory('fcc:boot:user'); @@ -39,6 +41,8 @@ function bootUser(app) { const deleteUserToken = createDeleteUserToken(app); const postMsUsername = createPostMsUsername(app); const deleteMsUsername = createDeleteMsUsername(app); + const postSubmitSurvey = createPostSubmitSurvey(app); + const deleteUserSurveys = createDeleteUserSurveys(app); api.get('/account', sendNonUserToHome, deprecatedEndpoint); api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial); @@ -48,6 +52,7 @@ function bootUser(app) { ifNoUser401, deleteUserToken, deleteMsUsername, + deleteUserSurveys, postDeleteAccount ); api.post( @@ -55,6 +60,7 @@ function bootUser(app) { ifNoUser401, deleteUserToken, deleteMsUsername, + deleteUserSurveys, postResetProgress ); api.post( @@ -80,6 +86,13 @@ function bootUser(app) { deleteMsUsernameResponse ); + api.post( + '/user/submit-survey', + ifNoUser401, + validateSurvey, + postSubmitSurvey + ); + app.use(api); } @@ -206,8 +219,50 @@ function deleteMsUsernameResponse(req, res) { return res.send({ msUsername: null }); } +function createPostSubmitSurvey(app) { + const { Survey } = app.models; + + return async function postSubmitSurvey(req, res) { + const { user, body } = req; + const { surveyResults } = body; + const { completedSurveys = [] } = user; + const { title } = surveyResults; + + const surveyAlreadyTaken = completedSurveys.some(s => s.title === title); + if (surveyAlreadyTaken) { + return res.status(400).json({ + type: 'error', + message: 'flash.survey.err-2' + }); + } + + try { + const newSurvey = { + ...surveyResults, + userId: user.id + }; + + const createdSurvey = await Survey.create(newSurvey); + if (!createdSurvey) { + throw new Error('Error creating survey'); + } + + return res.json({ + type: 'success', + message: 'flash.survey.success' + }); + } catch (e) { + log(e); + return res.status(500).json({ + type: 'error', + message: 'flash.survey.err-3' + }); + } + }; +} + function createReadSessionUser(app) { - const { MsUsername, UserToken } = app.models; + const { MsUsername, Survey, UserToken } = app.models; return async function getSessionUser(req, res, next) { const queryUser = req.user; @@ -240,6 +295,18 @@ function createReadSessionUser(app) { return next(e); } + let completedSurveys; + try { + const userId = queryUser?.id; + completedSurveys = userId + ? await Survey.find({ + where: { userId } + }) + : []; + } catch (e) { + return next(e); + } + if (!queryUser || !queryUser.toJSON().username) { // TODO: This should return an error status return res.json({ user: {}, result: '' }); @@ -282,7 +349,8 @@ function createReadSessionUser(app) { ...normaliseUserFields(user), joinDate: user.id.getTimestamp(), userToken: encodedUserToken, - msUsername + msUsername, + completedSurveys: completedSurveys.map(fixCompletedSurveyItem) } }, result: user.username diff --git a/api-server/src/server/middlewares/survey.js b/api-server/src/server/middlewares/survey.js new file mode 100644 index 00000000000..34d736ca3d1 --- /dev/null +++ b/api-server/src/server/middlewares/survey.js @@ -0,0 +1,41 @@ +import debugFactory from 'debug'; +const log = debugFactory('fcc:boot:user'); + +const allowedTitles = ['Foundational C# with Microsoft Survey']; + +export function validateSurvey(req, res, next) { + const { + surveyResults: { title = '', responses = [] } + } = req.body; + + if ( + !allowedTitles.includes(title) || + !Array.isArray(responses) || + !responses.every( + r => typeof r.question === 'string' && typeof r.response === 'string' + ) + ) { + return res.status(400).json({ + type: 'error', + message: 'flash.survey.err-1' + }); + } + + next(); +} + +export function createDeleteUserSurveys(app) { + const { Survey } = app.models; + + return async function deleteUserSurveys(req, res, next) { + try { + await Survey.destroyAll({ userId: req.user.id }); + req.userSurveysDeleted = true; + } catch (e) { + req.userSurveysDeleted = false; + log(`An error occurred deleting Surveys for user with id ${req.user.id}`); + } + + next(); + }; +} diff --git a/api-server/src/server/model-config.json b/api-server/src/server/model-config.json index 76270b42cef..788dcfcba41 100644 --- a/api-server/src/server/model-config.json +++ b/api-server/src/server/model-config.json @@ -47,6 +47,10 @@ "dataSource": "db", "public": false }, + "Survey": { + "dataSource": "db", + "public": false + }, "RoleMapping": { "dataSource": "db", "public": false diff --git a/api-server/src/server/models/survey.json b/api-server/src/server/models/survey.json new file mode 100644 index 00000000000..ff3f9833862 --- /dev/null +++ b/api-server/src/server/models/survey.json @@ -0,0 +1,41 @@ +{ + "name": "Survey", + "description": "Survey responses from campers", + "base": "PersistedModel", + "idInjection": true, + "options": { + "strict": true + }, + "properties": { + "title": { + "type": "string", + "required": true + }, + "responses": { + "type": [ + { + "question": "string", + "response": "string" + } + ], + "required": true + } + }, + "validations": [], + "relations": { + "user": { + "type": "belongsTo", + "model": "user", + "foreignKey": "userId" + } + }, + "acls": [ + { + "accessType": "*", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + } + ], + "methods": {} +} diff --git a/api-server/src/server/utils/publicUserProps.js b/api-server/src/server/utils/publicUserProps.js index bc93750b36f..f5b8a71cb33 100644 --- a/api-server/src/server/utils/publicUserProps.js +++ b/api-server/src/server/utils/publicUserProps.js @@ -5,6 +5,7 @@ export const publicUserProps = [ 'calendar', 'completedChallenges', 'completedExams', + 'completedSurveys', 'githubProfile', 'isApisMicroservicesCert', 'isBackEndCert', diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 63c1c9b2a02..56a628fbd2b 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -745,6 +745,12 @@ "verified": "Your trophy from Microsoft's learning platform was verified." } }, + "survey": { + "err-1": "The survey submitted is not in the correct format.", + "err-2": "It looks like you have already completed this survey.", + "err-3": "Something went wrong trying to save your survey.", + "success": "Thank you. Your survey was submitted." + }, "classroom-mode-updated": "We have updated your classroom mode settings" }, "validation": { @@ -960,5 +966,31 @@ "p2": "We thank you for reporting bugs that you encounter and help in making freeCodeCamp.org better.", "p3": "Your progress MAY NOT be saved on your next visit, and any certifications claimed on this deployment are not valid. Learn more by <0>following this link.", "certain": "Accept and Dismiss" + }, + "survey": { + "foundational-c-sharp": { + "title": "Foundational C# with Microsoft Survey", + "q1": { + "q": "Please describe your role:", + "o1": "Student developer", + "o2": "Beginner developer (less than 2 years experience)", + "o3": "Intermediate developer (between 2 and 5 years experience)", + "o4": "Experienced developer (more than 5 years experience)" + }, + "q2": { + "q": "Prior to this course, how experienced were you with .NET and C#?", + "o1": "Novice (no prior experience)", + "o2": "Beginner", + "o3": "Intermediate", + "o4": "Advanced", + "o5": "Expert" + } + }, + "misc": { + "take": "Take the survey", + "submit": "Submit the survey", + "exit": "Exit the survey", + "two-questions": "Congratulations on getting this far. Before you can start the exam, please answer these two short survey questions." + } } } diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index 2f0b20daa6c..d74e6de583f 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -47,6 +47,10 @@ export enum FlashMessages { ReportSent = 'flash.report-sent', SigninSuccess = 'flash.signin-success', StartProjectErr = 'flash.start-project-err', + SurveyErr1 = 'flash.survey.err-1', + SurveyErr2 = 'flash.survey.err-2', + SurveyErr3 = 'flash.survey.err-3', + SurveySuccess = 'flash.survey.success', TimelinePrivate = 'flash.timeline-private', TokenDeleted = 'flash.token-deleted', UpdatedAboutMe = 'flash.updated-about-me', diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index eb76b6168c0..3856fe515b8 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -31,6 +31,8 @@ export const actionTypes = createTypes( 'setMsUsername', 'setIsProcessing', 'submitComplete', + 'submitSurvey', + 'submitSurveyComplete', 'updateComplete', 'updateFailed', 'updateDonationFormState', diff --git a/client/src/redux/actions.js b/client/src/redux/actions.js index 19b27272c35..8184f8ba569 100644 --- a/client/src/redux/actions.js +++ b/client/src/redux/actions.js @@ -106,6 +106,11 @@ export const linkMsUsername = createAction(actionTypes.linkMsUsername); export const unlinkMsUsername = createAction(actionTypes.unlinkMsUsername); export const setMsUsername = createAction(actionTypes.setMsUsername); +export const submitSurvey = createAction(actionTypes.submitSurvey); +export const submitSurveyComplete = createAction( + actionTypes.submitSurveyComplete +); + export const setIsProcessing = createAction(actionTypes.setIsProcessing); export const closeSignoutModal = createAction(actionTypes.closeSignoutModal); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 109efa81303..97e85a524ce 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -23,6 +23,7 @@ import { createShowCertSaga } from './show-cert-saga'; import updateCompleteEpic from './update-complete-epic'; import { createUserTokenSaga } from './user-token-saga'; import { createMsUsernameSaga } from './ms-username-saga'; +import { createSurveySaga } from './survey-saga'; const defaultFetchState = { pending: true, @@ -91,7 +92,8 @@ export const sagas = [ ...createReportUserSaga(actionTypes), ...createUserTokenSaga(actionTypes), ...createSaveChallengeSaga(actionTypes), - ...createMsUsernameSaga(actionTypes) + ...createMsUsernameSaga(actionTypes), + ...createSurveySaga(actionTypes) ]; function spreadThePayloadOnUser(state, payload) { @@ -432,6 +434,23 @@ export const reducer = handleActions( } }; }, + [actionTypes.submitSurveyComplete]: ( + state, + { payload: { surveyResults } } + ) => { + const { appUsername } = state; + const { completedSurveys = [] } = state.user[appUsername]; + return { + ...state, + user: { + ...state.user, + [appUsername]: { + ...state.user[appUsername], + completedSurveys: [...completedSurveys, surveyResults] + } + } + }; + }, [challengeTypes.challengeMounted]: (state, { payload }) => ({ ...state, currentChallengeId: payload diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 6fee3edae96..d0c84819a56 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -210,6 +210,7 @@ export type User = { about: string; acceptedPrivacyTerms: boolean; completedChallenges: CompletedChallenge[]; + completedSurveys: SurveyResults[]; currentChallengeId: string; email: string; emailVerified: boolean; @@ -412,3 +413,14 @@ export interface GeneratedExamResults { passed: boolean; examTimeInSeconds: number; } + +// Survey related types +export interface SurveyResponse { + question: string; + response: string; +} + +export interface SurveyResults { + title: string; + responses: SurveyResponse[]; +} diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index 63010e76750..f5af6242cde 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -91,6 +91,9 @@ export const msUsernameSelector = state => { return userSelector(state).msUsername; }; +export const completedSurveysSelector = state => + userSelector(state).completedSurveys || []; + export const isProcessingSelector = state => { return state[MainApp].isProcessing; }; diff --git a/client/src/redux/survey-saga.js b/client/src/redux/survey-saga.js new file mode 100644 index 00000000000..293c31101e9 --- /dev/null +++ b/client/src/redux/survey-saga.js @@ -0,0 +1,40 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; + +import { createFlashMessage } from '../components/Flash/redux'; +import { FlashMessages } from '../components/Flash/redux/flash-messages'; +import { postSubmitSurvey } from '../utils/ajax'; +import { closeModal } from '../templates/Challenges/redux/actions'; +import { submitSurveyComplete, setIsProcessing } from './actions'; + +const submitSurveyErr = { + type: 'danger', + message: FlashMessages.SurveyErr3 +}; + +function* submitSurveySaga({ payload }) { + const surveyResults = payload; + + try { + const { data } = yield call(postSubmitSurvey, { surveyResults }); + const { type } = data; + + if (type === 'success') { + yield put(submitSurveyComplete({ surveyResults })); + yield put(createFlashMessage(data)); + yield put(closeModal('survey')); + yield put(setIsProcessing(false)); + } else { + yield put(createFlashMessage(data)); + yield put(closeModal('survey')); + yield put(setIsProcessing(false)); + } + } catch { + yield put(createFlashMessage(submitSurveyErr)); + yield put(closeModal('survey')); + yield put(setIsProcessing(false)); + } +} + +export function createSurveySaga(types) { + return [takeEvery(types.submitSurvey, submitSurveySaga)]; +} diff --git a/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey-alert.tsx b/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey-alert.tsx new file mode 100644 index 00000000000..366dc0ca14b --- /dev/null +++ b/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey-alert.tsx @@ -0,0 +1,52 @@ +import { Button, Panel } from '@freecodecamp/react-bootstrap'; +import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; +import { useTranslation } from 'react-i18next'; +import { openModal } from '../../redux/actions'; +import Spacer from '../../../../components/helpers/spacer'; +import SurveyModal from './survey-modal'; + +const mapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + openSurveyModal: () => openModal('survey') + }, + dispatch + ); + +interface FoudationalCSharpSurveyAlertProps { + openSurveyModal: () => void; +} + +function FoudationalCSharpSurveyAlert({ + openSurveyModal +}: FoudationalCSharpSurveyAlertProps): JSX.Element { + const { t } = useTranslation(); + + return ( + + {t('survey.foundational-c-sharp.title')} + +

{t('survey.misc.two-questions')}

+ + + +
+
+ ); +} + +FoudationalCSharpSurveyAlert.displayName = 'FoundationalCSharpSurveyAlert'; + +export default connect(null, mapDispatchToProps)(FoudationalCSharpSurveyAlert); diff --git a/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey.tsx b/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey.tsx new file mode 100644 index 00000000000..ec938551a97 --- /dev/null +++ b/client/src/templates/Challenges/exam/components/foundational-c-sharp-survey.tsx @@ -0,0 +1,198 @@ +import { Button, Modal } from '@freecodecamp/react-bootstrap'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import type { Dispatch } from 'redux'; +import { createSelector } from 'reselect'; +import { SurveyResults, SurveyResponse } from '../../../../redux/prop-types'; +import { Spacer } from '../../../../components/helpers'; +import { setIsProcessing, submitSurvey } from '../../../../redux/actions'; +import { closeModal } from '../../redux/actions'; +import { isProcessingSelector } from '../../../../redux/selectors'; + +interface FoundationalCSharpSurveyProps { + closeSurveyModal: () => void; + submitSurvey: (arg0: SurveyResults) => void; + isProcessing: boolean; + setIsProcessing: (arg0: boolean) => void; +} + +interface SurveyState { + questionIndex: number; + responseIndex: null | number; +} + +const mapStateToProps = createSelector( + isProcessingSelector, + (isProcessing: boolean) => ({ + isProcessing + }) +); + +const mapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + closeSurveyModal: () => closeModal('survey'), + setIsProcessing, + submitSurvey + }, + dispatch + ); + +function FoundationalCSharpSurvey({ + closeSurveyModal, + submitSurvey, + setIsProcessing, + isProcessing +}: FoundationalCSharpSurveyProps): JSX.Element { + const { t } = useTranslation(); + + // submit English values to server and save those to database + const englishTitle = t('survey.foundational-c-sharp.title', { lng: 'en' }); + const englishSurvey = [ + { + question: t('survey.foundational-c-sharp.q1.q', { lng: 'en' }), + options: [ + t('survey.foundational-c-sharp.q1.o1', { lng: 'en' }), + t('survey.foundational-c-sharp.q1.o2', { lng: 'en' }), + t('survey.foundational-c-sharp.q1.o3', { lng: 'en' }), + t('survey.foundational-c-sharp.q1.o4', { lng: 'en' }) + ] + }, + { + question: t('survey.foundational-c-sharp.q2.q', { lng: 'en' }), + options: [ + t('survey.foundational-c-sharp.q2.o1', { lng: 'en' }), + t('survey.foundational-c-sharp.q2.o2', { lng: 'en' }), + t('survey.foundational-c-sharp.q2.o3', { lng: 'en' }), + t('survey.foundational-c-sharp.q2.o4', { lng: 'en' }), + t('survey.foundational-c-sharp.q2.o5', { lng: 'en' }) + ] + } + ]; + + // display survey in i18n + const i18nSurvey = [ + { + question: t('survey.foundational-c-sharp.q1.q'), + options: [ + t('survey.foundational-c-sharp.q1.o1'), + t('survey.foundational-c-sharp.q1.o2'), + t('survey.foundational-c-sharp.q1.o3'), + t('survey.foundational-c-sharp.q1.o4') + ] + }, + { + question: t('survey.foundational-c-sharp.q2.q'), + options: [ + t('survey.foundational-c-sharp.q2.o1'), + t('survey.foundational-c-sharp.q2.o2'), + t('survey.foundational-c-sharp.q2.o3'), + t('survey.foundational-c-sharp.q2.o4'), + t('survey.foundational-c-sharp.q2.o5') + ] + } + ]; + + const emptySurvey: SurveyState[] = i18nSurvey.map((question, i) => ({ + questionIndex: i, + responseIndex: null + })); + + const [surveyResponses, setSurveyResponses] = useState(emptySurvey); + + function handleOptionChange(questionIndex: number, responseIndex: number) { + const newSurveyResponses = Array.from(surveyResponses); + newSurveyResponses[questionIndex].responseIndex = responseIndex; + setSurveyResponses(newSurveyResponses); + } + + function createSurveyResults() { + setIsProcessing(true); + + // convert responses to English before submitting + const englishResponses: SurveyResponse[] = surveyResponses.map(r => ({ + question: englishSurvey[r.questionIndex].question, + response: + englishSurvey[r.questionIndex].options[r.responseIndex as number] + })); + + const surveyResults = { + title: englishTitle, + responses: englishResponses + }; + + submitSurvey(surveyResults); + } + + const cantSubmitSurvey = surveyResponses.some(q => q.responseIndex === null); + + return ( + <> + + + {t('survey.foundational-c-sharp.title')} + + + + {i18nSurvey.map((question, i) => ( +
+ +
{question.question}
+ +
+ {question.options.map((option, j) => ( + + ))} +
+
+ ))} +
+ + + + + + ); +} + +FoundationalCSharpSurvey.displayName = 'FoundationalCSharpSurvey'; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FoundationalCSharpSurvey); diff --git a/client/src/templates/Challenges/exam/components/missing-prerequisites.tsx b/client/src/templates/Challenges/exam/components/missing-prerequisites.tsx new file mode 100644 index 00000000000..57f880ce005 --- /dev/null +++ b/client/src/templates/Challenges/exam/components/missing-prerequisites.tsx @@ -0,0 +1,31 @@ +import { Alert } from '@freecodecamp/react-bootstrap'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Spacer from '../../../../components/helpers/spacer'; +import { PrerequisiteChallenge } from '../../../../redux/prop-types'; + +interface MissingPrerequisitesProps { + missingPrerequisites: PrerequisiteChallenge[]; +} + +function MissingPrerequisites({ + missingPrerequisites +}: MissingPrerequisitesProps): JSX.Element { + const { t } = useTranslation(); + + return ( + +

{t('learn.exam.not-qualified')}

+ + +
+ ); +} + +MissingPrerequisites.displayName = 'MissingPrerequisites'; + +export default MissingPrerequisites; diff --git a/client/src/templates/Challenges/exam/components/survey-modal.tsx b/client/src/templates/Challenges/exam/components/survey-modal.tsx new file mode 100644 index 00000000000..f55c53ab186 --- /dev/null +++ b/client/src/templates/Challenges/exam/components/survey-modal.tsx @@ -0,0 +1,55 @@ +import { Modal } from '@freecodecamp/react-bootstrap'; +import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; +import { createSelector } from 'reselect'; + +import { closeModal } from '../../redux/actions'; +import { isSurveyModalOpenSelector } from '../../redux/selectors'; +import { isProcessingSelector } from '../../../../redux/selectors'; +import FoundationalCSharpSurvey from './foundational-c-sharp-survey'; + +interface SurveyModalProps { + closeSurveyModal: () => void; + isProcessing: boolean; + isSurveyModalOpen: boolean; +} + +const mapStateToProps = createSelector( + isProcessingSelector, + isSurveyModalOpenSelector, + (isProcessing: boolean, isSurveyModalOpen: boolean) => ({ + isProcessing, + isSurveyModalOpen + }) +); + +const mapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + closeSurveyModal: () => closeModal('survey') + }, + dispatch + ); + +function SurveyModal({ + closeSurveyModal, + isSurveyModalOpen, + isProcessing +}: SurveyModalProps): JSX.Element { + return ( + (isProcessing ? '' : closeSurveyModal())} + show={isSurveyModalOpen} + > + + + ); +} + +SurveyModal.displayName = 'SurveyModal'; + +export default connect(mapStateToProps, mapDispatchToProps)(SurveyModal); diff --git a/client/src/templates/Challenges/exam/show.tsx b/client/src/templates/Challenges/exam/show.tsx index e9b4c780bc3..1c09a3ad3b4 100644 --- a/client/src/templates/Challenges/exam/show.tsx +++ b/client/src/templates/Challenges/exam/show.tsx @@ -24,6 +24,7 @@ import Hotkeys from '../components/hotkeys'; import { clearExamResults, startExam, stopExam } from '../../../redux/actions'; import { completedChallengesSelector, + completedSurveysSelector, isSignedInSelector, examInProgressSelector, examResultsSelector @@ -47,30 +48,38 @@ import { UserExamQuestion, UserExam, GeneratedExamResults, - GeneratedExamQuestion + GeneratedExamQuestion, + PrerequisiteChallenge, + SurveyResults } from '../../../redux/prop-types'; import { FlashMessages } from '../../../components/Flash/redux/flash-messages'; import { formatSecondsToTime } from '../../../utils/format-seconds'; import ExitExamModal from './components/exit-exam-modal'; import FinishExamModal from './components/finish-exam-modal'; import ExamResults from './components/exam-results'; +import MissingPrerequisites from './components/missing-prerequisites'; +import FoundationCSharpSurveyAlert from './components/foundational-c-sharp-survey-alert'; + import './exam.css'; // Redux const mapStateToProps = createSelector( completedChallengesSelector, + completedSurveysSelector, isChallengeCompletedSelector, isSignedInSelector, examInProgressSelector, examResultsSelector, ( completedChallenges: CompletedChallenge[], + completedSurveys: SurveyResults[], isChallengeCompleted: boolean, isSignedIn: boolean, examInProgress: boolean, examResults: GeneratedExamResults | null ) => ({ completedChallenges, + completedSurveys, isChallengeCompleted, isSignedIn, examInProgress, @@ -102,6 +111,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => interface ShowExamProps { challengeMounted: (arg0: string) => void; completedChallenges: CompletedChallenge[]; + completedSurveys: SurveyResults[]; clearExamResults: () => void; createFlashMessage: typeof createFlashMessage; data: { challengeNode: ChallengeNode }; @@ -334,6 +344,7 @@ class ShowExam extends Component { examInProgress, examResults, completedChallenges, + completedSurveys, isChallengeCompleted, openExitExamModal, openFinishExamModal, @@ -350,12 +361,7 @@ class ShowExam extends Component { userExamQuestions } = this.state; - type Prerequisite = { - id: string; - title: string; - }; - - let missingPrerequisites: Prerequisite[] = []; + let missingPrerequisites: PrerequisiteChallenge[] = []; if (prerequisites) { missingPrerequisites = prerequisites?.filter( prerequisite => @@ -363,7 +369,11 @@ class ShowExam extends Component { ); } - const qualifiedForExam = missingPrerequisites.length === 0; + const surveyCompleted = completedSurveys.some( + s => s.title === 'Foundational C# with Microsoft Survey' + ); + const prerequisitesComplete = missingPrerequisites.length === 0; + const qualifiedForExam = prerequisitesComplete && surveyCompleted; const blockNameTitle = `${t( `intro:${superBlock}.blocks.${block}.title` @@ -391,7 +401,10 @@ class ShowExam extends Component { {title} | -
+
{t('learn.exam.time', { t: formatSecondsToTime(examTimeInSeconds) })} @@ -456,7 +469,7 @@ class ShowExam extends Component { className='exam-button' disabled={currentQuestionIndex <= 0} bsStyle='primary' - data-cy='previous-exam-question' + data-cy='previous-exam-question-btn' onClick={this.goToPreviousQuestion} > {t('buttons.previous-question')} @@ -471,7 +484,7 @@ class ShowExam extends Component { } className='exam-button' bsStyle='primary' - data-cy='finish-exam' + data-cy='finish-exam-btn' onClick={openFinishExamModal} > {t('buttons.finish-exam')} @@ -484,7 +497,7 @@ class ShowExam extends Component { } className='exam-button' bsStyle='primary' - data-cy='next-exam-question' + data-cy='next-exam-question-btn' onClick={this.goToNextQuestion} > {t('buttons.next-question')} @@ -499,7 +512,7 @@ class ShowExam extends Component { block={true} className='exam-button' bsStyle='primary' - data-cy='exit-exam' + data-cy='exit-exam-btn' onClick={openExitExamModal} > {t('buttons.exit-exam')} @@ -532,19 +545,19 @@ class ShowExam extends Component { {qualifiedForExam ? ( - +

{t('learn.exam.qualified')}

) : ( - -

{t('learn.exam.not-qualified')}

- -
    - {missingPrerequisites.map(({ title, id }) => ( -
  • {title}
  • - ))} -
-
+ <> + {!prerequisitesComplete ? ( + + ) : ( + + )} + )} @@ -554,7 +567,7 @@ class ShowExam extends Component {