feat(client/api): add C# survey (#51682)

This commit is contained in:
Tom
2023-11-07 09:04:12 -06:00
committed by GitHub
parent a297c3b9bb
commit 369368a799
30 changed files with 939 additions and 32 deletions

View File

@@ -428,6 +428,11 @@
"type": "hasMany",
"model": "MsUsername",
"foreignKey": "userId"
},
"surveys": {
"type": "hasMany",
"model": "Survey",
"foreignKey": "userId"
}
},
"acls": [

View File

@@ -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']);

View File

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

View File

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

View File

@@ -47,6 +47,10 @@
"dataSource": "db",
"public": false
},
"Survey": {
"dataSource": "db",
"public": false
},
"RoleMapping": {
"dataSource": "db",
"public": false

View File

@@ -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": {}
}

View File

@@ -5,6 +5,7 @@ export const publicUserProps = [
'calendar',
'completedChallenges',
'completedExams',
'completedSurveys',
'githubProfile',
'isApisMicroservicesCert',
'isBackEndCert',

View File

@@ -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</0>.",
"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."
}
}
}

View File

@@ -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',

View File

@@ -31,6 +31,8 @@ export const actionTypes = createTypes(
'setMsUsername',
'setIsProcessing',
'submitComplete',
'submitSurvey',
'submitSurveyComplete',
'updateComplete',
'updateFailed',
'updateDonationFormState',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<Panel data-cy='c-sharp-survey-alert' bsStyle='info'>
<Panel.Heading>{t('survey.foundational-c-sharp.title')}</Panel.Heading>
<Panel.Body className='text-center'>
<p>{t('survey.misc.two-questions')}</p>
<Spacer size='small' />
<Button
block={true}
bsSize='md'
bsStyle='info'
data-cy='start-csharp-survey-btn'
className='btn-invert'
onClick={openSurveyModal}
type='button'
>
{t('survey.misc.take')}
</Button>
<SurveyModal />
</Panel.Body>
</Panel>
);
}
FoudationalCSharpSurveyAlert.displayName = 'FoundationalCSharpSurveyAlert';
export default connect(null, mapDispatchToProps)(FoudationalCSharpSurveyAlert);

View File

@@ -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 (
<>
<Modal.Header data-cy='c-sharp-survey-modal' closeButton={true}>
<Modal.Title className='text-center'>
{t('survey.foundational-c-sharp.title')}
</Modal.Title>
</Modal.Header>
<Modal.Body className='reset-modal-body'>
{i18nSurvey.map((question, i) => (
<div key={i}>
<Spacer size='medium' />
<div>{question.question}</div>
<Spacer size='small' />
<div className='video-quiz-options'>
{question.options.map((option, j) => (
<label className='video-quiz-option-label' key={j}>
<input
aria-label={t('aria.answer')}
checked={surveyResponses[i].responseIndex === j}
className='sr-only'
name={question.question}
onChange={() => handleOptionChange(i, j)}
type='radio'
value={option}
/>{' '}
<span className='video-quiz-input-visible'>
{surveyResponses[i].responseIndex === j ? (
<span className='video-quiz-selected-input' />
) : null}
</span>
{option}{' '}
</label>
))}
</div>
</div>
))}
</Modal.Body>
<Modal.Footer className='reset-modal-footer'>
<Button
block={true}
bsSize='medium'
bsStyle='primary'
data-cy='submit-csharp-survey-btn'
disabled={cantSubmitSurvey || isProcessing}
onClick={createSurveyResults}
>
{t('survey.misc.submit')}
</Button>
<Button
block={true}
bsSize='medium'
bsStyle='primary'
disabled={isProcessing}
onClick={closeSurveyModal}
>
{t('survey.misc.exit')}
</Button>
</Modal.Footer>
</>
);
}
FoundationalCSharpSurvey.displayName = 'FoundationalCSharpSurvey';
export default connect(
mapStateToProps,
mapDispatchToProps
)(FoundationalCSharpSurvey);

View File

@@ -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 (
<Alert data-cy='missing-prerequisites-alert' bsStyle='danger'>
<p>{t('learn.exam.not-qualified')}</p>
<Spacer size='small' />
<ul>
{missingPrerequisites.map(({ title, id }) => (
<li key={id}>{title}</li>
))}
</ul>
</Alert>
);
}
MissingPrerequisites.displayName = 'MissingPrerequisites';
export default MissingPrerequisites;

View File

@@ -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 (
<Modal
animation={false}
dialogClassName='survey-modal'
keyboard={true}
onHide={() => (isProcessing ? '' : closeSurveyModal())}
show={isSurveyModalOpen}
>
<FoundationalCSharpSurvey />
</Modal>
);
}
SurveyModal.displayName = 'SurveyModal';
export default connect(mapStateToProps, mapDispatchToProps)(SurveyModal);

View File

@@ -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<ShowExamProps, ShowExamState> {
examInProgress,
examResults,
completedChallenges,
completedSurveys,
isChallengeCompleted,
openExitExamModal,
openFinishExamModal,
@@ -350,12 +361,7 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
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<ShowExamProps, ShowExamState> {
);
}
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<ShowExamProps, ShowExamState> {
{title}
</div>
<span>|</span>
<div data-playwright-test-label='exam-show-question-time'>
<div
data-cy='exam-time'
data-playwright-test-label='exam-show-question-time'
>
{t('learn.exam.time', {
t: formatSecondsToTime(examTimeInSeconds)
})}
@@ -456,7 +469,7 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
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<ShowExamProps, ShowExamState> {
}
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<ShowExamProps, ShowExamState> {
}
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<ShowExamProps, ShowExamState> {
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<ShowExamProps, ShowExamState> {
<Spacer size='medium' />
{qualifiedForExam ? (
<Alert id='qualified-for-exam' variant='info'>
<Alert data-cy='qualified-for-exam-alert' variant='info'>
<p>{t('learn.exam.qualified')}</p>
</Alert>
) : (
<Alert id='not-qualified-for-exam' variant='danger'>
<p>{t('learn.exam.not-qualified')}</p>
<Spacer size='small' />
<ul>
{missingPrerequisites.map(({ title, id }) => (
<li key={id}>{title}</li>
))}
</ul>
</Alert>
<>
{!prerequisitesComplete ? (
<MissingPrerequisites
missingPrerequisites={missingPrerequisites}
/>
) : (
<FoundationCSharpSurveyAlert />
)}
</>
)}
<PrismFormatted text={description} />
@@ -554,7 +567,7 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
<Button
block={true}
bsStyle='primary'
data-cy='start-exam'
data-cy='start-exam-btn'
disabled={!qualifiedForExam}
onClick={this.runExam}
>

View File

@@ -43,6 +43,7 @@ const initialState = {
exitExam: false,
finishExam: false,
examResults: false,
survey: false,
projectPreview: false,
shortcuts: false
},

View File

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

View File

@@ -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<ResponseWithData<void>> {
return post('/user/submit-survey', body);
}
/** PUT **/
interface MyAbout {

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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']

View File

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