feat: add video lessons to db on submit + update UI (#38591)

* feat: add video lessons to db on submit + update UI

* feat: delete CompletionVideoModal

* feat: clean up component + add comments

* feat: remove comment

* feat: remove log

* feat: remove log

* fix: update buttons + fix some testing

* fix: remove unused selector
This commit is contained in:
Tom
2020-04-21 10:07:51 -05:00
committed by Mrugesh Mohapatra
parent e776529ed0
commit 63fe67e53f
6 changed files with 143 additions and 326 deletions

View File

@ -1,301 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import noop from 'lodash/noop';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Modal } from '@freecodecamp/react-bootstrap';
import { useStaticQuery, graphql } from 'gatsby';
import SanitizedSpan from '../components/SanitizedSpan';
import Login from '../../../components/Header/components/Login';
import './completion-modal.css';
import {
closeModal,
submitChallenge,
completedChallengesIds,
isCompletionModalOpenSelector,
challengeFilesSelector,
challengeMetaSelector,
lastBlockChalSubmitted
} from '../redux';
import { isSignedInSelector, executeGA } from '../../../redux';
const mapStateToProps = createSelector(
challengeFilesSelector,
challengeMetaSelector,
completedChallengesIds,
isCompletionModalOpenSelector,
isSignedInSelector,
(files, { title, id }, completedChallengesIds, isOpen, isSignedIn) => ({
files,
title,
id,
completedChallengesIds,
isOpen,
isSignedIn
})
);
const mapDispatchToProps = function(dispatch) {
const dispatchers = {
close: () => dispatch(closeModal('completion')),
submitChallenge: () => {
dispatch(submitChallenge());
},
lastBlockChalSubmitted: () => {
dispatch(lastBlockChalSubmitted());
},
executeGA
};
return () => dispatchers;
};
const propTypes = {
answers: PropTypes.array,
blockName: PropTypes.string,
close: PropTypes.func.isRequired,
completedChallengesIds: PropTypes.array,
currentBlockIds: PropTypes.array,
executeGA: PropTypes.func,
files: PropTypes.object.isRequired,
id: PropTypes.string,
isOpen: PropTypes.bool,
isSignedIn: PropTypes.bool.isRequired,
lastBlockChalSubmitted: PropTypes.func,
question: PropTypes.string,
solution: PropTypes.number,
submitChallenge: PropTypes.func.isRequired,
title: PropTypes.string
};
export function getCompletedPercent(
completedChallengesIds = [],
currentBlockIds = [],
currentChallengeId
) {
completedChallengesIds = completedChallengesIds.includes(currentChallengeId)
? completedChallengesIds
: [...completedChallengesIds, currentChallengeId];
const completedChallengesInBlock = completedChallengesIds.filter(id => {
return currentBlockIds.includes(id);
});
const completedPercent = Math.round(
(completedChallengesInBlock.length / currentBlockIds.length) * 100
);
return completedPercent > 100 ? 100 : completedPercent;
}
export class CompletionModalInner extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleKeypress = this.handleKeypress.bind(this);
}
state = {
downloadURL: null,
completedPercent: 0,
selectedOption: 0,
answer: 1,
showWrong: false
};
static getDerivedStateFromProps(props, state) {
const { files, isOpen } = props;
if (!isOpen) {
return null;
}
const { downloadURL } = state;
if (downloadURL) {
URL.revokeObjectURL(downloadURL);
}
let newURL = null;
if (Object.keys(files).length) {
const filesForDownload = Object.keys(files)
.map(key => files[key])
.reduce((allFiles, { path, contents }) => {
const beforeText = `** start of ${path} **\n\n`;
const afterText = `\n\n** end of ${path} **\n\n`;
allFiles +=
files.length > 1 ? beforeText + contents + afterText : contents;
return allFiles;
}, '');
const blob = new Blob([filesForDownload], {
type: 'text/json'
});
newURL = URL.createObjectURL(blob);
}
const { completedChallengesIds, currentBlockIds, id, isSignedIn } = props;
let completedPercent = isSignedIn
? getCompletedPercent(completedChallengesIds, currentBlockIds, id)
: 0;
return { downloadURL: newURL, completedPercent: completedPercent };
}
handleKeypress(e) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// Since Hotkeys also listens to Ctrl + Enter we have to stop this event
// getting to it.
e.stopPropagation();
this.handleSubmit();
}
}
handleSubmit() {
if (this.props.solution - 1 === this.state.selectedOption) {
this.setState({
showWrong: false
});
this.props.submitChallenge();
this.checkBlockCompletion();
} else {
this.setState({
showWrong: true
});
}
}
// check block completion for donation
checkBlockCompletion() {
if (
this.state.completedPercent === 100 &&
!this.props.completedChallengesIds.includes(this.props.id)
) {
this.props.lastBlockChalSubmitted();
}
}
componentWillUnmount() {
if (this.state.downloadURL) {
URL.revokeObjectURL(this.state.downloadURL);
}
this.props.close();
}
handleOptionChange = changeEvent => {
console.log(this.state.selectedOption);
this.setState({
selectedOption: parseInt(changeEvent.target.value, 10)
});
};
render() {
const { close, isOpen, isSignedIn, question, answers } = this.props;
if (isOpen) {
executeGA({ type: 'modal', data: '/completion-modal' });
}
return (
<Modal
animation={false}
bsSize='lg'
dialogClassName='challenge-success-modal'
keyboard={true}
onHide={close}
onKeyDown={isOpen ? this.handleKeypress : noop}
show={isOpen}
>
<Modal.Header
className='challenge-list-header fcc-modal'
closeButton={true}
>
<Modal.Title className='completion-message'>Video Quiz</Modal.Title>
</Modal.Header>
<Modal.Body className='question-modal-body'>
<SanitizedSpan text={question} />
<form>
{answers.map((option, index) => (
<div className='form-check'>
<label>
<input
checked={this.state.selectedOption === index}
name='quiz'
onChange={this.handleOptionChange}
type='radio'
value={index}
/>{' '}
<SanitizedSpan text={option} />
</label>
</div>
))}
</form>
<div
style={{
visibility: this.state.showWrong ? 'visible' : 'hidden'
}}
>
Wrong. Try again.
</div>
</Modal.Body>
<Modal.Footer>
{isSignedIn ? null : (
<Login
block={true}
bsSize='lg'
bsStyle='primary'
className='btn-cta'
>
Sign in to save your progress
</Login>
)}
<Button
block={true}
bsSize='large'
bsStyle='primary'
onClick={this.handleSubmit}
>
Submit answer <span className='hidden-xs'>(Ctrl + Enter)</span>
</Button>
</Modal.Footer>
</Modal>
);
}
}
CompletionModalInner.propTypes = propTypes;
const useCurrentBlockIds = blockName => {
const {
allChallengeNode: { edges }
} = useStaticQuery(graphql`
query getCurrentBlockNodesVid {
allChallengeNode(sort: { fields: [superOrder, order, challengeOrder] }) {
edges {
node {
fields {
blockName
}
id
}
}
}
}
`);
const currentBlockIds = edges
.filter(edge => edge.node.fields.blockName === blockName)
.map(edge => edge.node.id);
return currentBlockIds;
};
const CompletionModal = props => {
const currentBlockIds = useCurrentBlockIds(props.blockName || '');
return <CompletionModalInner currentBlockIds={currentBlockIds} {...props} />;
};
CompletionModal.displayName = 'CompletionModal';
CompletionModal.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(CompletionModal);

View File

@ -50,8 +50,12 @@ function postChallenge(update, username) {
} }
function submitModern(type, state) { function submitModern(type, state) {
const challengeType = state.challenge.challengeMeta.challengeType;
const tests = challengeTestsSelector(state); const tests = challengeTestsSelector(state);
if (tests.length > 0 && tests.every(test => test.pass && !test.err)) { if (
challengeType === 11 ||
(tests.length > 0 && tests.every(test => test.pass && !test.err))
) {
if (type === types.checkChallenge) { if (type === types.checkChallenge) {
return of({ type: 'this was a check challenge' }); return of({ type: 'this was a check challenge' });
} }

View File

@ -1,3 +1,4 @@
// Package Utilities
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Grid, Col, Row } from '@freecodecamp/react-bootstrap'; import { Button, Grid, Col, Row } from '@freecodecamp/react-bootstrap';
@ -6,24 +7,35 @@ import { bindActionCreators } from 'redux';
import { graphql } from 'gatsby'; import { graphql } from 'gatsby';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import YouTube from 'react-youtube'; import YouTube from 'react-youtube';
import { createSelector } from 'reselect';
// Local Utilities
import SanitizedSpan from '../components/SanitizedSpan';
import { ChallengeNode } from '../../../redux/propTypes'; import { ChallengeNode } from '../../../redux/propTypes';
import LearnLayout from '../../../components/layouts/Learn';
import ChallengeTitle from '../components/Challenge-Title';
import ChallengeDescription from '../components/Challenge-Description';
import Spacer from '../../../components/helpers/Spacer';
import CompletionModal from '../components/CompletionModal';
import Hotkeys from '../components/Hotkeys';
import { import {
isChallengeCompletedSelector,
challengeMounted, challengeMounted,
updateChallengeMeta, updateChallengeMeta,
openModal, openModal,
updateProjectFormValues updateProjectFormValues
} from '../redux'; } from '../redux';
import LearnLayout from '../../../components/layouts/Learn'; // Styles
import ChallengeTitle from '../components/Challenge-Title'; import './show.css';
import ChallengeDescription from '../components/Challenge-Description';
import Spacer from '../../../components/helpers/Spacer';
import CompletionVideoModal from '../components/CompletionVideoModal';
import HelpModal from '../components/HelpModal';
import Hotkeys from '../components/Hotkeys';
const mapStateToProps = () => ({}); // Redux Setup
const mapStateToProps = createSelector(
isChallengeCompletedSelector,
isChallengeCompleted => ({
isChallengeCompleted
})
);
const mapDispatchToProps = dispatch => const mapDispatchToProps = dispatch =>
bindActionCreators( bindActionCreators(
{ {
@ -35,12 +47,14 @@ const mapDispatchToProps = dispatch =>
dispatch dispatch
); );
// Proptypes
const propTypes = { const propTypes = {
challengeMounted: PropTypes.func.isRequired, challengeMounted: PropTypes.func.isRequired,
data: PropTypes.shape({ data: PropTypes.shape({
challengeNode: ChallengeNode challengeNode: ChallengeNode
}), }),
description: PropTypes.string, description: PropTypes.string,
isChallengeCompleted: PropTypes.bool,
openCompletionModal: PropTypes.func.isRequired, openCompletionModal: PropTypes.func.isRequired,
pageContext: PropTypes.shape({ pageContext: PropTypes.shape({
challengeMeta: PropTypes.object challengeMeta: PropTypes.object
@ -49,12 +63,19 @@ const propTypes = {
updateProjectFormValues: PropTypes.func.isRequired updateProjectFormValues: PropTypes.func.isRequired
}; };
// Component
export class Project extends Component { export class Project extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
subtitles: '' subtitles: '',
downloadURL: null,
selectedOption: 0,
answer: 1,
showWrong: false
}; };
this.handleSubmit = this.handleSubmit.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -95,6 +116,25 @@ export class Project extends Component {
} }
} }
handleSubmit(solution, openCompletionModal) {
if (solution - 1 === this.state.selectedOption) {
this.setState({
showWrong: false
});
openCompletionModal();
} else {
this.setState({
showWrong: true
});
}
}
handleOptionChange = changeEvent => {
this.setState({
selectedOption: parseInt(changeEvent.target.value, 10)
});
};
render() { render() {
const { const {
data: { data: {
@ -109,10 +149,11 @@ export class Project extends Component {
openCompletionModal, openCompletionModal,
pageContext: { pageContext: {
challengeMeta: { introPath, nextChallengePath, prevChallengePath } challengeMeta: { introPath, nextChallengePath, prevChallengePath }
} },
isChallengeCompleted
} = this.props; } = this.props;
const blockNameTitle = `${blockName} - ${title}`;
const blockNameTitle = `${blockName} - ${title}`;
return ( return (
<Hotkeys <Hotkeys
innerRef={c => (this._container = c)} innerRef={c => (this._container = c)}
@ -126,7 +167,9 @@ export class Project extends Component {
<Row> <Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}> <Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer /> <Spacer />
<ChallengeTitle>{blockNameTitle}</ChallengeTitle> <ChallengeTitle isCompleted={isChallengeCompleted}>
{blockNameTitle}
</ChallengeTitle>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<YouTube <YouTube
onEnd={openCompletionModal} onEnd={openCompletionModal}
@ -150,24 +193,51 @@ export class Project extends Component {
</div> </div>
<Spacer /> <Spacer />
<ChallengeDescription description={description} /> <ChallengeDescription description={description} />
<Spacer />
<SanitizedSpan text={text} />
<Spacer />
<div className='video-quiz-options'>
{answers.map((option, index) => (
<label className='video-quiz-option-label'>
<input
checked={this.state.selectedOption === index}
className='video-quiz-input-hidden'
name='quiz'
onChange={this.handleOptionChange}
type='radio'
value={index}
/>{' '}
<span className='video-quiz-input-visible'>
{this.state.selectedOption === index ? (
<span className='video-quiz-selected-input'></span>
) : null}
</span>
<SanitizedSpan text={option} />
</label>
))}
</div>
<Spacer />
<Button <Button
block={true} block={true}
bsSize='large' bsSize='large'
bsStyle='primary' bsStyle='primary'
onClick={openCompletionModal} onClick={() =>
this.handleSubmit(solution, openCompletionModal)
}
> >
Complete Check your answer
</Button> </Button>
<Spacer /> <Spacer />
<div
style={{
visibility: this.state.showWrong ? 'visible' : 'hidden'
}}
>
Wrong. Try again.
</div>
<Spacer size={2} />
</Col> </Col>
<CompletionVideoModal <CompletionModal blockName={blockName} />
answers={answers}
blockName={blockName}
question={text}
solution={solution}
/>
<HelpModal />
</Row> </Row>
</Grid> </Grid>
</LearnLayout> </LearnLayout>

View File

@ -0,0 +1,43 @@
.video-quiz-options {
padding: 1px 16px;
background-color: var(--tertiary-background);
}
.video-quiz-option-label {
display: block;
margin: 20px;
cursor: pointer;
display: flex;
font-weight: normal;
}
.video-quiz-input-hidden {
position: absolute;
left: -9999px;
}
.video-quiz-input-visible {
margin-right: 15px;
position: relative;
top: 2px;
display: inline-block;
min-width: 20px;
min-height: 20px;
max-width: 20px;
max-height: 20px;
border-radius: 50%;
background-color: var(--secondary-background);
border: 2px solid var(--primary-color);
position: relative;
}
.video-quiz-selected-input {
width: 10px;
height: 10px;
position: absolute;
top: 50%;
left: 50%;
background-color: var(--primary-color);
border-radius: 50%;
transform: translate(-50%, -50%);
}

View File

@ -125,5 +125,6 @@ exports.helpCategory = {
'data-analysis-with-python': 'Certification Projects', 'data-analysis-with-python': 'Certification Projects',
'data-analysis-with-python-projects': 'Certification Projects', 'data-analysis-with-python-projects': 'Certification Projects',
'machine-learning-with-python': 'Certification Projects', 'machine-learning-with-python': 'Certification Projects',
'machine-learning-with-python-projects': 'Certification Projects' 'machine-learning-with-python-projects': 'Certification Projects',
'python-for-everybody': 'Python'
}; };

View File

@ -10,7 +10,7 @@ function getSchemaForLang(lang) {
challengeOrder: Joi.number(), challengeOrder: Joi.number(),
challengeType: Joi.number() challengeType: Joi.number()
.min(0) .min(0)
.max(10) .max(11)
.required(), .required(),
checksum: Joi.number(), checksum: Joi.number(),
dashedName: Joi.string(), dashedName: Joi.string(),