Merge pull request #38 from Bouncey/feat/codeStorage

Feat:  Add code storage and lesson reset modal
This commit is contained in:
Stuart Taylor
2018-05-09 12:40:09 +01:00
committed by Mrugesh Mohapatra
parent 68eefa5565
commit bb6b3869ed
12 changed files with 266 additions and 29 deletions

View File

@ -46,6 +46,7 @@
"redux-observable": "^0.18.0",
"reselect": "^3.0.1",
"rxjs": "^5.5.7",
"store": "^2.0.12",
"uglifyjs-webpack-plugin": "^1.2.4",
"validator": "^9.4.1"
},

View File

@ -8,16 +8,16 @@ const clientID = AUTH0_CLIENT_ID;
class Auth {
constructor() {
this.auth0 = new auth0.WebAuth({
domain,
clientID,
redirectUri: `${
typeof window !== 'undefined' ? window.location.origin : ''
}/auth-callback`,
audience: `https://${domain}/api/v2/`,
responseType: 'token id_token',
scope: `openid profile email ${namespace + 'accountLinkId'}`
});
this.auth0 = new auth0.WebAuth({
domain,
clientID,
redirectUri: `${
typeof window !== 'undefined' ? window.location.origin : ''
}/auth-callback`,
audience: `https://${domain}/api/v2/`,
responseType: 'token id_token',
scope: `openid profile email ${namespace + 'accountLinkId'}`
});
this.getUser = this.getUser.bind(this);
this.getToken = this.getToken.bind(this);

View File

@ -82,7 +82,6 @@ class Editor extends PureComponent {
render() {
const { contents, ext } = this.props;
return (
<div className='classic-editor editor'>
<base href='/' />

View File

@ -12,6 +12,7 @@ import Preview from '../components/Preview';
import SidePanel from '../components/Side-Panel';
import CompletionModal from '../components/CompletionModal';
import HelpModal from '../components/HelpModal';
import ResetModal from '../components/ResetModal';
import { challengeTypes } from '../../../../utils/challengeTypes';
import { ChallengeNode } from '../../../redux/propTypes';
@ -19,7 +20,8 @@ import {
createFiles,
challengeFilesSelector,
initTests,
updateChallengeMeta
updateChallengeMeta,
challengeMounted
} from '../redux';
import './classic.css';
@ -29,9 +31,13 @@ const mapStateToProps = createSelector(challengeFilesSelector, files => ({
}));
const mapDispatchToProps = dispatch =>
bindActionCreators({ createFiles, initTests, updateChallengeMeta }, dispatch);
bindActionCreators(
{ createFiles, initTests, updateChallengeMeta, challengeMounted },
dispatch
);
const propTypes = {
challengeMounted: PropTypes.func.isRequired,
createFiles: PropTypes.func.isRequired,
data: PropTypes.shape({
challengeNode: ChallengeNode
@ -51,6 +57,7 @@ const propTypes = {
class ShowClassic extends PureComponent {
componentDidMount() {
const {
challengeMounted,
createFiles,
initTests,
updateChallengeMeta,
@ -60,11 +67,13 @@ class ShowClassic extends PureComponent {
createFiles(files);
initTests(tests);
updateChallengeMeta({ ...challengeMeta, title });
challengeMounted(challengeMeta.id);
}
componentDidUpdate(prevProps) {
const { data: { challengeNode: { title: prevTitle } } } = prevProps;
const {
challengeMounted,
createFiles,
initTests,
updateChallengeMeta,
@ -77,6 +86,7 @@ class ShowClassic extends PureComponent {
createFiles(files);
initTests(tests);
updateChallengeMeta({ ...challengeMeta, title: currentTitle });
challengeMounted(challengeMeta.id);
}
}
@ -135,6 +145,7 @@ class ShowClassic extends PureComponent {
</ReflexContainer>
<CompletionModal />
<HelpModal />
<ResetModal />
</Fragment>
);
}

View File

@ -28,12 +28,10 @@ const mapDispatchToProps = function(dispatch) {
close: () => dispatch(closeModal('completion')),
handleKeypress: e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
console.log('dispatching');
dispatch(submitChallenge());
}
},
submitChallenge: () => {
console.log('dispatching');
dispatch(submitChallenge());
}
};

View File

@ -0,0 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Button, Modal } from 'react-bootstrap';
import { isResetModalOpenSelector, closeModal, resetChallenge } from '../redux';
const propTypes = {
close: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
reset: PropTypes.func.isRequired
};
const mapStateToProps = createSelector(isResetModalOpenSelector, isOpen => ({
isOpen
}));
const mapDispatchToProps = dispatch =>
bindActionCreators(
{ close: () => closeModal('reset'), reset: () => resetChallenge() },
dispatch
);
function withActions(...fns) {
return () => fns.forEach(fn => fn());
}
function ResetModal({ reset, close, isOpen }) {
return (
<Modal
animation={false}
dialogClassName={'reset-modal'}
keyboard={true}
onHide={close}
show={isOpen}
>
<Modal.Header className={'challenge-list-header'} closeButton={true}>
<Modal.Title>Reset this lesson?</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className='text-center'>
<p>
Are you sure you wish to reset this lesson? The editors and tests
will be reset.
</p>
<p>
<em>This cannot be undone</em>.
</p>
</div>
</Modal.Body>
<Modal.Footer>
<Button
block={true}
bsSize='large'
bsStyle='danger'
onClick={withActions(reset, close)}
>
Reset this Lesson
</Button>
</Modal.Footer>
</Modal>
);
}
ResetModal.displayName = 'ResetModal';
ResetModal.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(ResetModal);

View File

@ -17,7 +17,6 @@ import {
consoleOutputSelector,
challengeTestsSelector,
executeChallenge,
resetChallenge,
initConsole,
openModal
} from '../redux';
@ -32,9 +31,9 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
executeChallenge,
resetChallenge,
initConsole,
openHelpModal: () => openModal('help')
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset')
},
dispatch
);
@ -45,8 +44,8 @@ const propTypes = {
guideUrl: PropTypes.string,
initConsole: PropTypes.func.isRequired,
openHelpModal: PropTypes.func.isRequired,
openResetModal: PropTypes.func.isRequired,
output: PropTypes.string,
resetChallenge: PropTypes.func.isRequired,
tests: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.string,
@ -89,7 +88,7 @@ export class SidePanel extends PureComponent {
output = '',
guideUrl,
executeChallenge,
resetChallenge,
openResetModal,
openHelpModal
} = this.props;
return (
@ -103,7 +102,7 @@ export class SidePanel extends PureComponent {
executeChallenge={executeChallenge}
guideUrl={guideUrl}
openHelpModal={openHelpModal}
reset={resetChallenge}
openResetModal={openResetModal}
/>
<Spacer />
<Output

View File

@ -7,10 +7,15 @@ const propTypes = {
executeChallenge: PropTypes.func.isRequired,
guideUrl: PropTypes.string,
openHelpModal: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired
openResetModal: PropTypes.func.isRequired
};
function ToolPanel({ executeChallenge, guideUrl, reset, openHelpModal }) {
function ToolPanel({
executeChallenge,
guideUrl,
openResetModal,
openHelpModal
}) {
return (
<div>
<Button
@ -26,7 +31,7 @@ function ToolPanel({ executeChallenge, guideUrl, reset, openHelpModal }) {
block={true}
bsStyle='primary'
className='btn-big'
onClick={reset}
onClick={openResetModal}
>
Reset this lesson
</Button>

View File

@ -0,0 +1,120 @@
import { of } from 'rxjs/observable/of';
import { filter } from 'rxjs/operators/filter';
import { switchMap } from 'rxjs/operators/switchMap';
import { tap } from 'rxjs/operators/tap';
import { ignoreElements } from 'rxjs/operators/ignoreElements';
import { combineEpics, ofType } from 'redux-observable';
import store from 'store';
import {
types,
storedCodeFound,
noStoredCodeFound,
isCodeLockedSelector,
challengeFilesSelector,
challengeMetaSelector
} from './';
import { setContent, isPoly } from '../utils/polyvinyl';
const legacyPrefixes = [
'Bonfire: ',
'Waypoint: ',
'Zipline: ',
'Basejump: ',
'Checkpoint: '
];
const legacyPostfix = 'Val';
function getCode(id) {
const code = store.get(id);
return code ? code : null;
}
function getLegacyCode(legacy) {
const key = legacy + legacyPostfix;
let code = null;
const maybeCode = store.get(key);
if (maybeCode) {
code = '' + maybeCode;
store.remove(key);
return code;
}
return legacyPrefixes.reduce((code, prefix) => {
if (code) {
return code;
}
return store.get(prefix + key);
}, null);
}
function legacyToFile(code, files, key) {
if (isFilesAllPoly(files)) {
return { [key]: setContent(code, files[key]) };
}
return false;
}
function isFilesAllPoly(files) {
return Object.keys(files)
.map(key => files[key])
.every(file => isPoly(file));
}
function clearCodeEpic(action$, { getState }) {
return action$.pipe(
ofType(types.submitComplete, types.resetChallenge),
tap(() => {
const { id } = challengeMetaSelector(getState());
store.remove(id);
}),
ignoreElements()
);
}
function saveCodeEpic(action$, { getState }) {
return action$.pipe(
tap(console.info),
ofType(types.executeChallenge),
// do not save challenge if code is locked
filter(() => !isCodeLockedSelector(getState())),
tap(() => {
const state = getState();
const { id } = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
store.set(id, files);
}),
ignoreElements()
);
}
function loadCodeEpic(action$, { getState }) {
return action$.pipe(
ofType(types.challengeMounted),
switchMap(({ payload: id }) => {
let finalFiles;
const state = getState();
const challenge = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
const fileKeys = Object.keys(files);
const invalidForLegacy = fileKeys.length > 1;
const { title: legacyKey } = challenge;
const codeFound = getCode(id);
if (codeFound && isFilesAllPoly(codeFound)) {
finalFiles = codeFound;
} else {
const legacyCode = getLegacyCode(legacyKey);
if (legacyCode && !invalidForLegacy) {
finalFiles = legacyToFile(legacyCode, files, fileKeys[0]);
}
}
if (finalFiles) {
return of(storedCodeFound(finalFiles));
}
return of(noStoredCodeFound());
})
);
}
export default combineEpics(saveCodeEpic, loadCodeEpic, clearCodeEpic);

View File

@ -5,7 +5,8 @@ import {
challengeFilesSelector,
challengeMetaSelector
} from '../redux';
import { tap, mapTo } from 'rxjs/operators';
import { tap } from 'rxjs/operators/tap';
import { mapTo } from 'rxjs/operators/mapTo';
function filesToMarkdown(files = {}) {
const moreThenOneFile = Object.keys(files).length > 1;

View File

@ -7,6 +7,7 @@ import completionEpic from './completion-epic';
import executeChallengeEpic from './execute-challenge-epic';
import codeLockEpic from './code-lock-epic';
import createQuestionEpic from './create-question-epic';
import codeStorageEpic from './code-storage-epic';
const ns = 'challenge';
export const backendNS = 'backendChallenge';
@ -19,10 +20,12 @@ const initialState = {
},
challengeTests: [],
consoleOut: '',
isCodeLocked: false,
isJSEnabled: true,
modal: {
completion: false,
help: false
help: false,
reset: false
},
successMessage: 'Happy Coding!'
};
@ -32,7 +35,8 @@ export const epics = [
codeLockEpic,
completionEpic,
createQuestionEpic,
executeChallengeEpic
executeChallengeEpic,
codeStorageEpic
];
export const types = createTypes(
@ -48,16 +52,21 @@ export const types = createTypes(
'updateSuccessMessage',
'updateTests',
'lockCode',
'unlockCode',
'disableJSOnError',
'storedCodeFound',
'noStoredCodeFound',
'closeModal',
'openModal',
'challengeMounted',
'checkChallenge',
'executeChallenge',
'resetChallenge',
'submitChallenge'
'submitChallenge',
'submitComplete'
],
ns
);
@ -88,28 +97,35 @@ export const updateConsole = createAction(types.updateConsole);
export const updateJSEnabled = createAction(types.updateJSEnabled);
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
export const lockCode = createAction(types.lockCode);
export const unlockCode = createAction(types.unlockCode);
export const disableJSOnError = createAction(types.disableJSOnError, err => {
console.error(err);
return {};
});
export const storedCodeFound = createAction(types.storedCodeFound);
export const noStoredCodeFound = createAction(types.noStoredCodeFound);
export const closeModal = createAction(types.closeModal);
export const openModal = createAction(types.openModal);
export const challengeMounted = createAction(types.challengeMounted);
export const checkChallenge = createAction(types.checkChallenge);
export const executeChallenge = createAction(types.executeChallenge);
export const resetChallenge = createAction(types.resetChallenge);
export const submitChallenge = createAction(types.submitChallenge);
export const submitComplete = createAction(types.submitComplete);
export const backendFormValuesSelector = state => state.form[backendNS];
export const challengeFilesSelector = state => state[ns].challengeFiles;
export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut;
export const isCodeLockedSelector = state => state[ns].isCodeLocked;
export const isCompletionModalOpenSelector = state =>
state[ns].modal.completion;
export const isHelpModalOpenSelector = state => state[ns].modal.help;
export const isResetModalOpenSelector = state => state[ns].modal.reset;
export const isJSEnabledSelector = state => state[ns].isJSEnabled;
export const successMessageSelector = state => state[ns].successMessage;
@ -129,6 +145,11 @@ export const reducer = handleActions(
}
}
}),
[types.storedCodeFound]: (state, { payload }) => ({
...state,
challengeFiles: payload
}),
[types.initTests]: (state, { payload }) => ({
...state,
challengeTests: payload
@ -137,6 +158,7 @@ export const reducer = handleActions(
...state,
challengeTests: payload
}),
[types.initConsole]: (state, { payload }) => ({
...state,
consoleOut: payload
@ -173,14 +195,21 @@ export const reducer = handleActions(
})),
consoleOut: ''
}),
[types.lockCode]: state => ({
...state,
isCodeLocked: true
}),
[types.unlockCode]: state => ({
...state,
isJSEnabled: true
isJSEnabled: true,
isCodeLocked: false
}),
[types.disableJSOnError]: state => ({
...state,
isJSEnabled: false
}),
[types.updateSuccessMessage]: (state, { payload }) => ({
...state,
successMessage: payload

View File

@ -10044,6 +10044,10 @@ steno@^0.4.1:
dependencies:
graceful-fs "^4.1.3"
store@^2.0.12:
version "2.0.12"
resolved "https://registry.yarnpkg.com/store/-/store-2.0.12.tgz#8c534e2a0b831f72b75fc5f1119857c44ef5d593"
stream-browserify@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"