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 link0>.", "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')} + + + {t('survey.misc.take')} + + + + + ); +} + +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) => ( + + handleOptionChange(i, j)} + type='radio' + value={option} + />{' '} + + {surveyResponses[i].responseIndex === j ? ( + + ) : null} + + {option}{' '} + + ))} + + + ))} + + + + {t('survey.misc.submit')} + + + {t('survey.misc.exit')} + + + > + ); +} + +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.map(({ title, id }) => ( + {title} + ))} + + + ); +} + +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 { diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index cc6e1c7ec6c..dc6bff25c2e 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -43,6 +43,7 @@ const initialState = { exitExam: false, finishExam: false, examResults: false, + survey: false, projectPreview: false, shortcuts: false }, diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js index 70901ef968a..80f1db3156d 100644 --- a/client/src/templates/Challenges/redux/selectors.js +++ b/client/src/templates/Challenges/redux/selectors.js @@ -31,6 +31,7 @@ export const isResetModalOpenSelector = state => state[ns].modal.reset; export const isExitExamModalOpenSelector = state => state[ns].modal.exitExam; export const isFinishExamModalOpenSelector = state => state[ns].modal.finishExam; +export const isSurveyModalOpenSelector = state => state[ns].modal.survey; export const isExamResultsModalOpenSelector = state => state[ns].modal.examResults; export const isProjectPreviewModalOpenSelector = state => diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 1492c70b181..ca592d6bc29 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -8,6 +8,7 @@ import type { GenerateExamResponseWithData, SavedChallenge, SavedChallengeFile, + SurveyResults, User } from '../redux/prop-types'; @@ -284,6 +285,12 @@ export function postSaveChallenge(body: { return post('/save-challenge', body); } +export function postSubmitSurvey(body: { + surveyResults: SurveyResults; +}): Promise> { + return post('/user/submit-survey', body); +} + /** PUT **/ interface MyAbout { diff --git a/client/src/utils/tone/index.ts b/client/src/utils/tone/index.ts index 058bffed524..ae6e543bd5a 100644 --- a/client/src/utils/tone/index.ts +++ b/client/src/utils/tone/index.ts @@ -62,6 +62,10 @@ const toneUrls = { [FlashMessages.ReportSent]: CHAL_COMP, [FlashMessages.SigninSuccess]: CHAL_COMP, [FlashMessages.StartProjectErr]: TRY_AGAIN, + [FlashMessages.SurveyErr1]: TRY_AGAIN, + [FlashMessages.SurveyErr2]: TRY_AGAIN, + [FlashMessages.SurveyErr3]: TRY_AGAIN, + [FlashMessages.SurveySuccess]: CHAL_COMP, [FlashMessages.TimelinePrivate]: TRY_AGAIN, [FlashMessages.TokenDeleted]: CHAL_COMP, [FlashMessages.UpdatedAboutMe]: CHAL_COMP, diff --git a/cypress.config.js b/cypress.config.js index e23a071f04f..63d73fd8e9c 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -6,6 +6,18 @@ function seed(args = []) { return execSync('node tools/scripts/seed/seed-demo-user ' + args.join(' ')); } +function seedExams() { + return execSync('node tools/scripts/seed-exams/create-exams.js'); +} + +function seedSurveys() { + return execSync('node tools/scripts/seed/seed-surveys.js'); +} + +function deleteSurveys() { + return execSync('node tools/scripts/seed/seed-surveys.js delete-only'); +} + module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:8000', @@ -35,7 +47,10 @@ module.exports = defineConfig({ } }); on('task', { - seed + seed, + seedExams, + seedSurveys, + deleteSurveys }); config.env.API_LOCATION = 'http://localhost:3000'; diff --git a/cypress/e2e/default/learn/challenges/c-sharp-exam.ts b/cypress/e2e/default/learn/challenges/c-sharp-exam.ts new file mode 100644 index 00000000000..d1f1729b0c3 --- /dev/null +++ b/cypress/e2e/default/learn/challenges/c-sharp-exam.ts @@ -0,0 +1,109 @@ +const examUrl = + '/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam'; + +const el = { + qualifiedAlert: "[data-cy='qualified-for-exam-alert']", + prerequisitesAlert: "[data-cy='missing-prerequisites-alert']", + surveyAlert: "[data-cy='c-sharp-survey-alert']", + startSurveyBtn: "[data-cy='start-csharp-survey-btn']", + submitSurveyBtn: "[data-cy='submit-csharp-survey-btn']", + surveyModal: "[data-cy='c-sharp-survey-modal']", + startExamBtn: "[data-cy='start-exam-btn']", + examTime: "[data-cy='exam-time']", + examInput: '.exam-answer-input-visible', + prevQuestionBtn: "[data-cy='previous-exam-question-btn']", + nextQuestionBtn: "[data-cy='next-exam-question-btn']", + exitExamBtn: "[data-cy='exit-exam-btn']", + finishExamBtn: "[data-cy='finish-exam-btn']" +}; + +describe('C# Exam Challenge', () => { + describe('Before completing prerequisite challenges', () => { + before(() => { + cy.task('seed'); + cy.login(); + }); + + it('Should show prerequisites alert and have "start exam" button disabled', () => { + cy.visit(examUrl); + cy.get(el.qualifiedAlert).should('not.exist'); + cy.get(el.prerequisitesAlert).should('be.visible'); + cy.contains('Trophy - Write Your First Code Using C#').should( + 'be.visible' + ); + cy.get(el.surveyAlert).should('not.exist'); + cy.get(el.startExamBtn).should('be.disabled'); + }); + }); + + describe('After completing prerequisite challenges', () => { + before(() => { + cy.task('seed', ['--seed-trophy-challenges']); + cy.task('deleteSurveys'); + cy.login('trophy-user'); + cy.visit(examUrl); + }); + + it('Should show the survey alert and be able to complete the survey', () => { + cy.contains('Trophy - Write Your First Code Using C#').should( + 'not.exist' + ); + cy.get(el.qualifiedAlert).should('not.exist'); + cy.get(el.prerequisitesAlert).should('not.exist'); + cy.get(el.surveyAlert).should('be.visible'); + cy.get(el.startExamBtn).should('be.disabled'); + cy.get(el.startSurveyBtn).click(); + cy.get(el.surveyModal).should('be.visible'); + cy.get(el.submitSurveyBtn).should('be.disabled'); + cy.contains('Student developer').click(); + cy.contains('Novice (no prior experience').click(); + cy.get(el.submitSurveyBtn).should('be.enabled'); + }); + }); + + describe('After completing the survey,', () => { + beforeEach(() => { + cy.task('seed', ['certified-user']); + cy.task('seedExams'); + cy.task('seedSurveys'); + cy.login('certified-user'); + cy.visit(examUrl); + }); + + it('Should be able to start and complete the exam', () => { + cy.get(el.qualifiedAlert).should('be.visible'); + cy.get(el.surveyAlert).should('not.exist'); + cy.get(el.prerequisitesAlert).should('not.exist'); + cy.get(el.startExamBtn).click(); + cy.get('.exam-wrapper').should('be.visible'); + cy.contains('Foundational C# with Microsoft Certification Exam').should( + 'be.visible' + ); + cy.get(el.examTime).should('be.visible'); + cy.contains('Question 1 of 5').should('be.visible'); + cy.get(el.prevQuestionBtn).should('be.disabled'); + cy.get(el.nextQuestionBtn).should('be.disabled'); + cy.get(el.finishExamBtn).should('not.exist'); + cy.get(el.exitExamBtn).should('be.visible'); + + // answer the first exam question + cy.get(el.examInput).eq(0).click(); + cy.get(el.nextQuestionBtn).click(); + cy.get(el.prevQuestionBtn).should('be.enabled'); + cy.get(el.nextQuestionBtn).should('be.disabled'); + cy.contains('Question 2 of 5').should('be.visible'); + + // answer the rest of the questions + cy.get(el.examInput).eq(0).click(); + cy.get(el.nextQuestionBtn).click(); + cy.get(el.examInput).eq(0).click(); + cy.get(el.nextQuestionBtn).click(); + cy.get(el.examInput).eq(0).click(); + cy.get(el.nextQuestionBtn).click(); + cy.get(el.examInput).eq(0).click(); + cy.get(el.finishExamBtn).click(); + cy.contains('Yes, I am finished').click(); + cy.get('.exam-results-wrapper').should('be.visible'); + }); + }); +}); diff --git a/package.json b/package.json index e97697160c7..f1ad251928b 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,10 @@ "preseed": "npm-run-all create:shared", "playwright:install-build-tools": "cd ./e2e && npm i && npx playwright install && npx playwright install-deps", "playwright:install-build-tools-linux": "sh ./playwright-install.sh", - "seed": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user", - "seed:certified-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user certified-user", + "seed": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user && pnpm seed:surveys", + "seed:certified-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user certified-user && pnpm seed:surveys", "seed:exams": "cd tools/scripts/seed-exams && cross-env node ./create-exams.js", + "seed:surveys": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seed-surveys", "serve:client": "cd ./client && pnpm run serve", "serve:client-ci": "cd ./client && pnpm run serve-ci", "start": "npm-run-all create:shared -p develop:server serve:client", diff --git a/tools/scripts/seed-exams/create-exams.js b/tools/scripts/seed-exams/create-exams.js index eff39b424a6..65bb30e10a4 100755 --- a/tools/scripts/seed-exams/create-exams.js +++ b/tools/scripts/seed-exams/create-exams.js @@ -42,7 +42,7 @@ function handleError(err, client) { const seed = async () => { for (const filename of examFilenames) { try { - const examPath = join('./exams', filename); + const examPath = join(__dirname, 'exams', filename); const examFile = readFileSync(examPath, { encoding: 'utf-8' }); const examJson = yaml.load(examFile); const validExam = validateExamSchema(examJson); diff --git a/tools/scripts/seed/seed-demo-user.js b/tools/scripts/seed/seed-demo-user.js index 38f97e3e69a..8d3c32e1c71 100644 --- a/tools/scripts/seed/seed-demo-user.js +++ b/tools/scripts/seed/seed-demo-user.js @@ -10,6 +10,7 @@ const allowedArgs = [ '--donor', '--top-contributor', '--unset-privacy-terms', + '--seed-trophy-challenges', 'certified-user' ]; @@ -45,6 +46,45 @@ function handleError(err, client) { } } +const trophyChallenges = [ + { + id: '647f85d407d29547b3bee1bb', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&locale=en-us', + completedDate: 1695064765244 + }, + { + id: '647f87dc07d29547b3bee1bf', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-2.trophy?username=moT01&locale=en-us', + completedDate: 1695064900926 + }, + { + id: '647f882207d29547b3bee1c0', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-3.trophy?username=moT01&locale=en-us', + completedDate: 1695064949460 + }, + { + id: '647f867a07d29547b3bee1bc', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-4.trophy?username=moT01&locale=en-us', + completedDate: 1695064986634 + }, + { + id: '647f877f07d29547b3bee1be', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-5.trophy?username=moT01&locale=en-us', + completedDate: 1695065026465 + }, + { + id: '647f86ff07d29547b3bee1bd', + solution: + 'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-6.trophy?username=moT01&locale=en-us', + completedDate: 1695065060157 + } +]; + const demoUser = { _id: new ObjectId('5bd30e0f1caf6ac3ddddddb5'), email: 'foo@bar.com', @@ -80,7 +120,9 @@ const demoUser = { isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, isFoundationalCSharpCertV8: false, - completedChallenges: [], + completedChallenges: args.includes('--seed-trophy-challenges') + ? trophyChallenges + : [], portfolio: [], yearsTopContributor: args.includes('--top-contributor') ? ['2017', '2018', '2019'] diff --git a/tools/scripts/seed/seed-surveys.js b/tools/scripts/seed/seed-surveys.js new file mode 100644 index 00000000000..2afdc581abc --- /dev/null +++ b/tools/scripts/seed/seed-surveys.js @@ -0,0 +1,99 @@ +const path = require('path'); +const debug = require('debug'); +require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') }); +const { MongoClient, ObjectId } = require('mongodb'); + +const args = process.argv.slice(2); + +const allowedArgs = ['delete-only']; + +// Check for invalid arguments +args.forEach(arg => { + if (!allowedArgs.includes(arg)) + throw new Error( + `Invalid argument ${arg}. Allowed arguments are ${allowedArgs.join(', ')}` + ); +}); + +const log = debug('fcc:tools:seedSurveyInfo'); +const { MONGOHQ_URL } = process.env; + +function handleError(err, client) { + if (err) { + console.error('Oh noes!! Error seeding survey info.'); + console.error(err); + try { + client.close(); + } catch (e) { + // no-op + } finally { + /* eslint-disable-next-line no-process-exit */ + process.exit(1); + } + } +} + +const surveyIds = [ + new ObjectId('651c5a2a5f9b639b584028bc'), + new ObjectId('651c5a4c5f9b639b584028bd') +]; + +const defaultUserSurvey = { + _id: surveyIds[0], + title: 'Foundational C# with Microsoft Survey', + responses: [ + { + question: 'Please describe your role:', + response: 'Beginner developer (less than 2 years experience)' + }, + { + question: + 'Prior to this course, how experienced were you with .NET and C#?', + response: 'Novice (no prior experience)' + } + ], + userId: new ObjectId('5bd30e0f1caf6ac3ddddddb5') +}; + +const certifiedUserSurvey = { + _id: surveyIds[1], + title: 'Foundational C# with Microsoft Survey', + responses: [ + { + question: 'Please describe your role:', + response: 'Experienced developer (more than 5 years experience)' + }, + { + question: + 'Prior to this course, how experienced were you with .NET and C#?', + response: 'Expert' + } + ], + userId: new ObjectId('5fa2db00a25c1c1fa49ce067') +}; + +const client = new MongoClient(MONGOHQ_URL, { useNewUrlParser: true }); + +log('Connected successfully to mongo'); + +const db = client.db('freecodecamp'); +const survey = db.collection('Survey'); + +const run = async () => { + await survey.deleteMany({ + _id: { + $in: surveyIds + } + }); + log('Survey info deleted'); + + if (!args.includes('delete-only')) { + await survey.insertOne(defaultUserSurvey); + await survey.insertOne(certifiedUserSurvey); + log('Survey info seeded'); + } +}; + +run() + .then(() => client.close()) + .catch(err => handleError(err, client));
{t('survey.misc.two-questions')}
{t('learn.exam.not-qualified')}
{t('learn.exam.qualified')}