From 9f875e1d11dbeb2cf0fb3b271f209eb4756a9b03 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Tue, 8 Aug 2017 15:31:26 -0400 Subject: [PATCH] feat(quiz): initial quiz view which can be used for multiple choice (#15743) quizes --- common/app/routes/challenges/Show.jsx | 4 +- common/app/routes/challenges/index.js | 4 +- .../challenges/redux/completion-epic.js | 1 + common/app/routes/challenges/redux/index.js | 4 +- common/app/routes/challenges/utils.js | 2 + common/app/routes/challenges/views/index.less | 1 + .../routes/challenges/views/project/Show.jsx | 3 +- .../routes/challenges/views/quiz/Choice.jsx | 82 +++++++ .../app/routes/challenges/views/quiz/Quiz.jsx | 222 ++++++++++++++++++ .../app/routes/challenges/views/quiz/Show.jsx | 33 +++ .../app/routes/challenges/views/quiz/index.js | 1 + .../app/routes/challenges/views/quiz/ns.json | 1 + .../routes/challenges/views/quiz/quiz.less | 93 ++++++++ .../challenges/views/quiz/redux/index.js | 79 +++++++ common/app/utils/challengeTypes.js | 1 + .../00-getting-started/getting-started.json | 8 +- .../javascript-multiple-choice-questions.json | 112 +++++++++ 17 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 common/app/routes/challenges/views/quiz/Choice.jsx create mode 100644 common/app/routes/challenges/views/quiz/Quiz.jsx create mode 100644 common/app/routes/challenges/views/quiz/Show.jsx create mode 100644 common/app/routes/challenges/views/quiz/index.js create mode 100644 common/app/routes/challenges/views/quiz/ns.json create mode 100644 common/app/routes/challenges/views/quiz/quiz.less create mode 100644 common/app/routes/challenges/views/quiz/redux/index.js create mode 100644 seed/challenges/08-coding-interview-questions-and-take-home-assignments/javascript-multiple-choice-questions.json diff --git a/common/app/routes/challenges/Show.jsx b/common/app/routes/challenges/Show.jsx index 1e03366527..1d274a9fe3 100644 --- a/common/app/routes/challenges/Show.jsx +++ b/common/app/routes/challenges/Show.jsx @@ -10,6 +10,7 @@ import Classic from './views/classic'; import Step from './views/step'; import Project from './views/project'; import BackEnd from './views/backend'; +import Quiz from './views/quiz'; import { challengeMetaSelector } from './redux'; import { @@ -27,7 +28,8 @@ const views = { classic: Classic, project: Project, simple: Project, - step: Step + step: Step, + quiz: Quiz }; const mapDispatchToProps = { diff --git a/common/app/routes/challenges/index.js b/common/app/routes/challenges/index.js index fff3d571d1..a0eea2dd3e 100644 --- a/common/app/routes/challenges/index.js +++ b/common/app/routes/challenges/index.js @@ -3,13 +3,15 @@ import { panesMap as backendPanesMap } from './views/backend'; import { panesMap as classicPanesMap } from './views/classic'; import { panesMap as stepPanesMap } from './views/step'; import { panesMap as projectPanesMap } from './views/project'; +import { panesMap as quizPanesMap } from './views/quiz'; export function createPanesMap() { return { ...backendPanesMap, ...classicPanesMap, ...stepPanesMap, - ...projectPanesMap + ...projectPanesMap, + ...quizPanesMap }; } diff --git a/common/app/routes/challenges/redux/completion-epic.js b/common/app/routes/challenges/redux/completion-epic.js index ab7265aab1..8432a1a8b3 100644 --- a/common/app/routes/challenges/redux/completion-epic.js +++ b/common/app/routes/challenges/redux/completion-epic.js @@ -138,6 +138,7 @@ const submitters = { backend: submitBackendChallenge, step: submitSimpleChallenge, video: submitSimpleChallenge, + quiz: submitSimpleChallenge, 'project.frontEnd': submitProject, 'project.backEnd': submitProject, 'project.simple': submitSimpleChallenge diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index 61cef59898..5feb5d5ca1 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -27,6 +27,7 @@ import { bonfire, html, js } from '../../../utils/challengeTypes'; import blockNameify from '../../../utils/blockNameify'; import { createPoly, setContent } from '../../../../utils/polyvinyl'; import createStepReducer, { epics as stepEpics } from '../views/step/redux'; +import createQuizReducer from '../views/quiz/redux'; import createProjectReducer from '../views/project/redux'; // this is not great but is ok until we move to a different form type @@ -361,6 +362,7 @@ export default function createReducers() { return [ reducer, ...createStepReducer(), - ...createProjectReducer() + ...createProjectReducer(), + ...createQuizReducer() ]; } diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 39140c3429..45ead3e449 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -14,6 +14,7 @@ export const viewTypes = { // formally hikes [ challengeTypes.video ]: 'video', [ challengeTypes.step ]: 'step', + [ challengeTypes.quiz ]: 'quiz', backend: 'backend' }; @@ -34,6 +35,7 @@ export const submitTypes = { // formally hikes [ challengeTypes.video ]: 'video', [ challengeTypes.step ]: 'step', + [ challengeTypes.quiz ]: 'quiz', backend: 'backend' }; diff --git a/common/app/routes/challenges/views/index.less b/common/app/routes/challenges/views/index.less index 839c5aa630..6a485e0b71 100644 --- a/common/app/routes/challenges/views/index.less +++ b/common/app/routes/challenges/views/index.less @@ -1,2 +1,3 @@ &{ @import "./classic/classic.less"; } &{ @import "./step/step.less"; } +&{ @import "./quiz/quiz.less"; } diff --git a/common/app/routes/challenges/views/project/Show.jsx b/common/app/routes/challenges/views/project/Show.jsx index 79fc4c204d..e3d470120f 100644 --- a/common/app/routes/challenges/views/project/Show.jsx +++ b/common/app/routes/challenges/views/project/Show.jsx @@ -2,12 +2,11 @@ import React from 'react'; import Main from './Project.jsx'; import { types } from '../../redux'; +import Panes from '../../../../Panes'; import _Map from '../../../../Map'; import ChildContainer from '../../../../Child-Container.jsx'; -import Panes from '../../../../Panes'; const propTypes = {}; - export const panesMap = { [types.toggleMap]: 'Map', [types.toggleMain]: 'Main' diff --git a/common/app/routes/challenges/views/quiz/Choice.jsx b/common/app/routes/challenges/views/quiz/Choice.jsx new file mode 100644 index 0000000000..0ba6e9c7e9 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/Choice.jsx @@ -0,0 +1,82 @@ +import React, { PropTypes, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import { createSelector } from 'reselect'; + +import { + selectChoice, + incrementCorrect +} from './redux'; + +const mapStateToProps = createSelector( + () => ({}) +); + +function mapDispatchToProps(dispatch) { + return () => bindActionCreators({ + selectChoice, + incrementCorrect + }, dispatch); +} + +const propTypes = { + choice: PropTypes.string, + choiceIndex: PropTypes.number, + incrementCorrect: PropTypes.func, + isChoiceSelected: PropTypes.bool, + isCorrectChoice: PropTypes.bool, + selectChoice: PropTypes.func, + selected: PropTypes.bool +}; + +export class Choice extends PureComponent { + + constructor(props) { + super(props); + this.selectChoice = this.selectChoice.bind(this); + } + + selectChoice() { + if (this.props.isChoiceSelected) { + return; + } + if (this.props.isCorrectChoice) { + this.props.incrementCorrect(); + } + this.props.selectChoice(this.props.choiceIndex); + } + + render() { + const choiceClass = classnames({ + choice: true, + selected: this.props.selected, + correct: this.props.isCorrectChoice, + reveal: this.props.isChoiceSelected + }); + + return ( +
+
+
+
+ +
+
+ ); + } +} + +Choice.displayName = 'Choice'; +Choice.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Choice); diff --git a/common/app/routes/challenges/views/quiz/Quiz.jsx b/common/app/routes/challenges/views/quiz/Quiz.jsx new file mode 100644 index 0000000000..d6490f2db0 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/Quiz.jsx @@ -0,0 +1,222 @@ +import React, { PropTypes, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { Col, Row } from 'react-bootstrap'; +import Choice from './Choice.jsx'; + +import { + currentIndexSelector, + selectedChoiceSelector, + nextQuestion, + selectChoice, + correctSelector, + incrementCorrect, + resetQuiz, + resetChoice +} from './redux'; + +import { submitChallenge, challengeMetaSelector } from '../../redux'; +import { challengeSelector } from '../../../../redux'; + +const mapStateToProps = createSelector( + challengeSelector, + challengeMetaSelector, + currentIndexSelector, + selectedChoiceSelector, + correctSelector, + ( + { + description = [], + title + }, + meta, + currentIndex, + selectedChoice, + correct + ) => ({ + title, + description, + meta, + currentIndex, + selectedChoice, + correct + }) +); + +function mapDispatchToProps(dispatch) { + return () => bindActionCreators({ + nextQuestion, + selectChoice, + incrementCorrect, + resetQuiz, + resetChoice, + submitChallenge + }, dispatch); +} + +const propTypes = { + correct: PropTypes.number, + currentIndex: PropTypes.number, + description: PropTypes.string, + meta: PropTypes.object, + nextQuestion: PropTypes.fun, + resetChoice: PropTypes.fun, + resetQuiz: PropTypes.fun, + selectedChoice: PropTypes.number, + submitChallenge: PropTypes.fun +}; + +export class QuizChallenge extends PureComponent { + + constructor(props) { + super(props); + this.nextQuestion = this.nextQuestion.bind(this); + this.submitChallenge = this.submitChallenge.bind(this); + } + + nextQuestion() { + this.props.resetChoice(); + this.props.nextQuestion(); + } + + submitChallenge() { + this.props.resetQuiz(); + this.props.submitChallenge(); + } + + renderTitle() { + return ( + + +

{this.props.meta.title}

+
+ +
+ ); + } + + renderResults() { + const isQuizPassed = this.props.correct === this.props.description.length; + return ( +
+ {this.renderTitle()} + + +

Quiz Results:

+ +

+ You got {this.props.correct} out of + {this.props.description.length} correct! +

+ + {isQuizPassed === false ? ( +
+

+ You will need to get all the questions + correct in order to mark this quiz as completed. +

+ +
+ ) : ( + + )} + +
+
+ ); + } + + renderQuiz() { + const currentIndex = this.props.currentIndex; + const question = this.props.description[currentIndex]; + return ( +
+ {this.renderTitle()} + + +

+ Question {currentIndex + 1} of {this.props.description.length}: +

+ +

+ {question.subtitle}: +

+ +

+ + + +

Choices

+ + {question.choices.map((choice, i) => ( + + ))} + +
+ + {this.props.selectedChoice !== null && + + +
+ {this.props.selectedChoice === question.answer + ?

+ Correct, great work! +

+ :

+ Sorry, that is not correct! +

} +
+ {this.props.selectedChoice !== question.answer && +
+

Explanation:

+

+

} + + + + + +
} +
+ ); + } + + render() { + return ( +
{ + this.props.currentIndex >= this.props.description.length ? + this.renderResults() : this.renderQuiz()} +
+ ); + } +} + +QuizChallenge.displayName = 'QuizChallenge'; +QuizChallenge.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(QuizChallenge); diff --git a/common/app/routes/challenges/views/quiz/Show.jsx b/common/app/routes/challenges/views/quiz/Show.jsx new file mode 100644 index 0000000000..ab31f006a4 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/Show.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import Main from './Quiz.jsx'; +import { types } from '../../redux'; +import Panes from '../../../../Panes'; +import _Map from '../../../../Map'; +import ChildContainer from '../../../../Child-Container.jsx'; + +const propTypes = {}; +export const panesMap = { + [types.toggleMap]: 'Map', + [types.toggleMain]: 'Main' +}; + +const nameToComponent = { + Map: { + Component: _Map + }, + Main: { + Component: Main + } +}; + +export default function ShowQuiz() { + return ( + + + + ); +} + +ShowQuiz.displayName = 'ShowQuiz'; +ShowQuiz.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/quiz/index.js b/common/app/routes/challenges/views/quiz/index.js new file mode 100644 index 0000000000..f8a8115a06 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/index.js @@ -0,0 +1 @@ +export { default, panesMap } from './Show.jsx'; diff --git a/common/app/routes/challenges/views/quiz/ns.json b/common/app/routes/challenges/views/quiz/ns.json new file mode 100644 index 0000000000..c2cb0377f5 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/ns.json @@ -0,0 +1 @@ +"quiz" diff --git a/common/app/routes/challenges/views/quiz/quiz.less b/common/app/routes/challenges/views/quiz/quiz.less new file mode 100644 index 0000000000..7b1e960ff9 --- /dev/null +++ b/common/app/routes/challenges/views/quiz/quiz.less @@ -0,0 +1,93 @@ +// should match ./ns.json value and filename +@ns: quiz; + +.quiz { + padding-left: 20px; + padding-right: 20px; + + .quizTitle { + margin-top: 10px; + text-align: center; + } + + .textCenter { + text-align: center; + } + + .explanation p { + text-align: left; + } + + .quizResults { + text-align: center; + } + + .wrongAnswer { + color: red; + } + + .correctAnswer { + color: green; + } + + .choice.selected { + .radio { + .inside { + opacity: 1; + } + } + } + + .choice:not(.reveal):hover > .radio { + .inside { + opacity: 1; + } + } + + .choice { + position: relative; + padding: 10px; + margin-bottom: 10px; + border-radius: 10px; + + .text { + + } + + .radio { + width: 24px; + height: 24px; + border-radius: 100%; + border: 4px solid #333; + float: left; + margin-right: 10px; + + .inside { + position: absolute; + width: 12px; + height: 12px; + left: 2px; + top: 2px; + background-color: #333; + border-radius: 100%; + opacity: 0; + } + } + } + + .choice:not(.reveal):hover { + background-color: rgba(0, 100, 0, 0.3); + } + + .choice:not(.reveal) { + cursor: pointer; + } + + .choice.reveal { + background-color: rgba(255, 65, 77, 0.3); + } + + .choice.reveal.correct { + background-color: rgba(0, 100, 0, 0.3); + } +} diff --git a/common/app/routes/challenges/views/quiz/redux/index.js b/common/app/routes/challenges/views/quiz/redux/index.js new file mode 100644 index 0000000000..ae31a5da3d --- /dev/null +++ b/common/app/routes/challenges/views/quiz/redux/index.js @@ -0,0 +1,79 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; +import noop from 'lodash/noop'; + +import ns from '../ns.json'; + +export const types = createTypes([ + 'nextQuestion', + 'selectChoice', + 'incrementCorrect', + 'resetQuiz', + 'resetChoice' +], ns); + +export const nextQuestion = createAction( + types.nextQuestion, + noop +); + +export const selectChoice = createAction( + types.selectChoice, + (selectedChoice) => ({ selectedChoice }) +); + +export const incrementCorrect = createAction( + types.incrementCorrect, + noop +); + +export const resetQuiz = createAction( + types.resetQuiz, + noop +); + +export const resetChoice = createAction( + types.resetChoice, + noop +); + +const initialState = { + currentIndex: 0, + selectedChoice: null, + correct: 0 +}; + +export const getNS = state => state[ns]; +export const currentIndexSelector = state => getNS(state).currentIndex; +export const selectedChoiceSelector = state => getNS(state).selectedChoice; +export const correctSelector = state => getNS(state).correct; + +export default function createReducers() { + const reducer = handleActions({ + [types.nextQuestion]: state => ({ + ...state, + currentIndex: state.currentIndex + 1 + }), + [types.selectChoice]: (state, {payload}) => ({ + ...state, + selectedChoice: payload.selectedChoice + }), + [types.incrementCorrect]: state => ({ + ...state, + correct: state.correct + 1 + }), + [types.resetQuiz]: state => ({ + ...state, + currentIndex: 0, + correct: 0, + selectedChoice: null + }), + [types.resetChoice]: state => ({ + ...state, + selectedChoice: null + }) + }, initialState); + + reducer.toString = () => ns; + return [ reducer ]; +} diff --git a/common/app/utils/challengeTypes.js b/common/app/utils/challengeTypes.js index 821a5fc721..e271239bb0 100644 --- a/common/app/utils/challengeTypes.js +++ b/common/app/utils/challengeTypes.js @@ -9,3 +9,4 @@ export const backEndProject = 4; export const bonfire = 5; export const video = 6; export const step = 7; +export const quiz = 8; diff --git a/seed/challenges/00-getting-started/getting-started.json b/seed/challenges/00-getting-started/getting-started.json index f7c5e87a74..a3a162e309 100644 --- a/seed/challenges/00-getting-started/getting-started.json +++ b/seed/challenges/00-getting-started/getting-started.json @@ -81,7 +81,13 @@ "" ] ], - "challengeSeed": [], + "challengeSeed": [ + "function sym(args) {", + " return args;", + "}", + "", + "sym([1, 2, 3], [5, 2, 1, 4]);" + ], "tests": [], "type": "Waypoint", "challengeType": 7, diff --git a/seed/challenges/08-coding-interview-questions-and-take-home-assignments/javascript-multiple-choice-questions.json b/seed/challenges/08-coding-interview-questions-and-take-home-assignments/javascript-multiple-choice-questions.json new file mode 100644 index 0000000000..5baca9cd39 --- /dev/null +++ b/seed/challenges/08-coding-interview-questions-and-take-home-assignments/javascript-multiple-choice-questions.json @@ -0,0 +1,112 @@ +{ + "name": "JavaScript Multiple Choice Questions", + "order": 8, + "time": "", + "helpRoom": "HelpJavaScript", + "challenges": [ + { + "id": "59874fc749228906236a3275", + "title": "Array.prototype.map", + "description": [ + { + "subtitle": "Flooring an Array", + "question": "What will the following code print out?\n
const results = [1.32, 2.43, 3.9]\n  .map(Math.floor);\nconsole.log(results);
", + "choices": [ + "
1.32 2.43 3.9
", + "
['1.32', '2.43', '3.9']
", + "
[1, 2, 3]
", + "
'1 2 3'
" + ], + "answer": 2, + "explanation": "The map function takes a callback function as it's first parameter and applies that function against every value inside the array. In this example, our callback function is the Math.floor function which will truncate the decimal points of all the numbers and convert them to integers." + }, + { + "subtitle": "Custom Map Functions", + "question": "What will the following code print out?\n
const results = ['a', 'b', 'c']\n  .map(a => [a.toUpperCase()]);\nconsole.log(results);
", + "choices": [ + "
[['A'], ['B'], ['C']]
", + "
['A', 'B', 'C']
", + "
['a', 'b', 'c]
", + "
'ABC'
" + ], + "answer": 0, + "explanation": "The map function will return a new array with each element equal to the old element ran through a callback function. Our callback function takes our original element, changes it to a upper case, and then wraps it in an array; thus, leaving us with [['A', 'B', 'C']]" + }, + { + "subtitle": "Maps on Maps", + "question": "What will the following code print out?\n
const results = [[4, 1], [2, 0], [3, 3]]\n  .map(a => \n    a.map(b => b % 2)[0] + a.map(b => b - 1)[1]\n  )\nconsole.log(results);
", + "choices": [ + "
[[0, 1], [0, 0], [1, 1]]
", + "
[[0, 0], [0, -1], [1, 2]]
", + "
[1, 1, 2]
", + "
[0, -1, 3]
" + ], + "answer": 3, + "explanation": "This answer can be explained by first looking at the example of what happens with the first element in our array, [4, 1]. Our first map callback will run a mod 2 map function over [4, 1] leaving us with a new array of [0, 1]. The second map call which is inside our callback will subtract 1 from every element, leaving us with [3, 0]. Last, we take element at index 0, 0, and add it to element of index 1 from our second map function, 0, leaving us with 0; thus, after the first iteration of the top level map function, we are left with an array that looks like so: [1, [2, 0], [3, 3]]. We simply keep doing that logic for the other elements until we finish: [1, -1, [3, 3]], and [1, -1, 3]" + }, + { + "subtitle": "Words Containing 'a'", + "question": "What will the following code print out?\n
const results = ['apple', 'dog', 'cat']\n  .map((a, i) => \n    a.indexOf('a') !== -1 ? i : null)\nconsole.log(results);
", + "choices": [ + "
[0, -1, 1]
", + "
[0, null, 2]
", + "
[null, null, null]
", + "
[-1, null, 2]
" + ], + "answer": 1, + "explanation": "This map example will return an array where each elements of the new array is either the original array index when the element contains the character 'a'; otherwise, an element of null for any words that do not have the character 'a'." + }, + { + "subtitle": "Accessing the Original Array Elements", + "question": "What will the following code print out?\n
const results = [1, 2, 3]\n  .map((a, _, o) => a + o[0])\nconsole.log(results);
", + "choices": [ + "
[1, 2, 3]
", + "
[0, 0, 0]
", + "
[3, 2, 1]
", + "
[2, 3, 4]
" + ], + "answer": 3, + "explanation": "This map example will add the value of the first element in the original array to all the other elements in the array." + }, + { + "subtitle": "More Map Hacking", + "question": "What will the following code print out?\n
const results = [8, 5, 3]\n  .map((a, i, o) => o[o.length - i - i])\nconsole.log(results);
", + "choices": [ + "
[3, 5, 8]
", + "
[5, 3, 8]
", + "
[8, 5, 3]
", + "
[3, 8, 5]
" + ], + "answer": 0, + "explanation": "This map example will reverse the array. The third argument to the map callback function is the original array; therefore, we can use the current index in the map function, and work our way backwards from the end of the array using the o.length." + }, + { + "subtitle": "Custom Scoping", + "question": "What will the following code print out?\n
const results = ['a', 'b', 'c']\n  .map(function(a) { return this[a]; }, {a: 9, b: 3, c: 1})\nconsole.log(results);
", + "choices": [ + "
['a', 'b', 'c']
", + "
[9, 3, 1]
", + "
[3, 9, 1]
", + "
[{a: 9}, {b: 3}, {c: 1}]
" + ], + "answer": 1, + "explanation": "This map example will reverse the array. The third argument to the map callback function is the original array; therefore, we can use the current index in the map function, and work our way backwards from the end of the array using the o.length." + }, + { + "subtitle": "Reversing in Map, Just Because", + "question": "What will the following code print out?\n
const results = [1, 2, 3, 4, 5]\n  .map((a, _, o) => o.reverse() && a)\nconsole.log(results);
", + "choices": [ + "
[5, 4, 3, 2, 1]
", + "
[5, 2, 3, 5, 1]
", + "
[1, 2, 3, 4, 5]
", + "
[1, 4, 3, 2, 5]
" + ], + "answer": 3, + "explanation": "This map example will reverse the array. The third argument to the map callback function is the original array; therefore, we can use the current index in the map function, and work our way backwards from the end of the array using the o.length." + } + ], + "tests": [], + "challengeType": 8 + } + ] +}