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", "redux-observable": "^0.18.0",
"reselect": "^3.0.1", "reselect": "^3.0.1",
"rxjs": "^5.5.7", "rxjs": "^5.5.7",
"store": "^2.0.12",
"uglifyjs-webpack-plugin": "^1.2.4", "uglifyjs-webpack-plugin": "^1.2.4",
"validator": "^9.4.1" "validator": "^9.4.1"
}, },

View File

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

View File

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

View File

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

View File

@ -28,12 +28,10 @@ const mapDispatchToProps = function(dispatch) {
close: () => dispatch(closeModal('completion')), close: () => dispatch(closeModal('completion')),
handleKeypress: e => { handleKeypress: e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
console.log('dispatching');
dispatch(submitChallenge()); dispatch(submitChallenge());
} }
}, },
submitChallenge: () => { submitChallenge: () => {
console.log('dispatching');
dispatch(submitChallenge()); 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, consoleOutputSelector,
challengeTestsSelector, challengeTestsSelector,
executeChallenge, executeChallenge,
resetChallenge,
initConsole, initConsole,
openModal openModal
} from '../redux'; } from '../redux';
@ -32,9 +31,9 @@ const mapDispatchToProps = dispatch =>
bindActionCreators( bindActionCreators(
{ {
executeChallenge, executeChallenge,
resetChallenge,
initConsole, initConsole,
openHelpModal: () => openModal('help') openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset')
}, },
dispatch dispatch
); );
@ -45,8 +44,8 @@ const propTypes = {
guideUrl: PropTypes.string, guideUrl: PropTypes.string,
initConsole: PropTypes.func.isRequired, initConsole: PropTypes.func.isRequired,
openHelpModal: PropTypes.func.isRequired, openHelpModal: PropTypes.func.isRequired,
openResetModal: PropTypes.func.isRequired,
output: PropTypes.string, output: PropTypes.string,
resetChallenge: PropTypes.func.isRequired,
tests: PropTypes.arrayOf( tests: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
text: PropTypes.string, text: PropTypes.string,
@ -89,7 +88,7 @@ export class SidePanel extends PureComponent {
output = '', output = '',
guideUrl, guideUrl,
executeChallenge, executeChallenge,
resetChallenge, openResetModal,
openHelpModal openHelpModal
} = this.props; } = this.props;
return ( return (
@ -103,7 +102,7 @@ export class SidePanel extends PureComponent {
executeChallenge={executeChallenge} executeChallenge={executeChallenge}
guideUrl={guideUrl} guideUrl={guideUrl}
openHelpModal={openHelpModal} openHelpModal={openHelpModal}
reset={resetChallenge} openResetModal={openResetModal}
/> />
<Spacer /> <Spacer />
<Output <Output

View File

@ -7,10 +7,15 @@ const propTypes = {
executeChallenge: PropTypes.func.isRequired, executeChallenge: PropTypes.func.isRequired,
guideUrl: PropTypes.string, guideUrl: PropTypes.string,
openHelpModal: PropTypes.func.isRequired, 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 ( return (
<div> <div>
<Button <Button
@ -26,7 +31,7 @@ function ToolPanel({ executeChallenge, guideUrl, reset, openHelpModal }) {
block={true} block={true}
bsStyle='primary' bsStyle='primary'
className='btn-big' className='btn-big'
onClick={reset} onClick={openResetModal}
> >
Reset this lesson Reset this lesson
</Button> </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, challengeFilesSelector,
challengeMetaSelector challengeMetaSelector
} from '../redux'; } from '../redux';
import { tap, mapTo } from 'rxjs/operators'; import { tap } from 'rxjs/operators/tap';
import { mapTo } from 'rxjs/operators/mapTo';
function filesToMarkdown(files = {}) { function filesToMarkdown(files = {}) {
const moreThenOneFile = Object.keys(files).length > 1; 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 executeChallengeEpic from './execute-challenge-epic';
import codeLockEpic from './code-lock-epic'; import codeLockEpic from './code-lock-epic';
import createQuestionEpic from './create-question-epic'; import createQuestionEpic from './create-question-epic';
import codeStorageEpic from './code-storage-epic';
const ns = 'challenge'; const ns = 'challenge';
export const backendNS = 'backendChallenge'; export const backendNS = 'backendChallenge';
@ -19,10 +20,12 @@ const initialState = {
}, },
challengeTests: [], challengeTests: [],
consoleOut: '', consoleOut: '',
isCodeLocked: false,
isJSEnabled: true, isJSEnabled: true,
modal: { modal: {
completion: false, completion: false,
help: false help: false,
reset: false
}, },
successMessage: 'Happy Coding!' successMessage: 'Happy Coding!'
}; };
@ -32,7 +35,8 @@ export const epics = [
codeLockEpic, codeLockEpic,
completionEpic, completionEpic,
createQuestionEpic, createQuestionEpic,
executeChallengeEpic executeChallengeEpic,
codeStorageEpic
]; ];
export const types = createTypes( export const types = createTypes(
@ -48,16 +52,21 @@ export const types = createTypes(
'updateSuccessMessage', 'updateSuccessMessage',
'updateTests', 'updateTests',
'lockCode',
'unlockCode', 'unlockCode',
'disableJSOnError', 'disableJSOnError',
'storedCodeFound',
'noStoredCodeFound',
'closeModal', 'closeModal',
'openModal', 'openModal',
'challengeMounted',
'checkChallenge', 'checkChallenge',
'executeChallenge', 'executeChallenge',
'resetChallenge', 'resetChallenge',
'submitChallenge' 'submitChallenge',
'submitComplete'
], ],
ns ns
); );
@ -88,28 +97,35 @@ export const updateConsole = createAction(types.updateConsole);
export const updateJSEnabled = createAction(types.updateJSEnabled); export const updateJSEnabled = createAction(types.updateJSEnabled);
export const updateSuccessMessage = createAction(types.updateSuccessMessage); export const updateSuccessMessage = createAction(types.updateSuccessMessage);
export const lockCode = createAction(types.lockCode);
export const unlockCode = createAction(types.unlockCode); export const unlockCode = createAction(types.unlockCode);
export const disableJSOnError = createAction(types.disableJSOnError, err => { export const disableJSOnError = createAction(types.disableJSOnError, err => {
console.error(err); console.error(err);
return {}; return {};
}); });
export const storedCodeFound = createAction(types.storedCodeFound);
export const noStoredCodeFound = createAction(types.noStoredCodeFound);
export const closeModal = createAction(types.closeModal); export const closeModal = createAction(types.closeModal);
export const openModal = createAction(types.openModal); export const openModal = createAction(types.openModal);
export const challengeMounted = createAction(types.challengeMounted);
export const checkChallenge = createAction(types.checkChallenge); export const checkChallenge = createAction(types.checkChallenge);
export const executeChallenge = createAction(types.executeChallenge); export const executeChallenge = createAction(types.executeChallenge);
export const resetChallenge = createAction(types.resetChallenge); export const resetChallenge = createAction(types.resetChallenge);
export const submitChallenge = createAction(types.submitChallenge); export const submitChallenge = createAction(types.submitChallenge);
export const submitComplete = createAction(types.submitComplete);
export const backendFormValuesSelector = state => state.form[backendNS]; export const backendFormValuesSelector = state => state.form[backendNS];
export const challengeFilesSelector = state => state[ns].challengeFiles; export const challengeFilesSelector = state => state[ns].challengeFiles;
export const challengeMetaSelector = state => state[ns].challengeMeta; export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeTestsSelector = state => state[ns].challengeTests; export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut; export const consoleOutputSelector = state => state[ns].consoleOut;
export const isCodeLockedSelector = state => state[ns].isCodeLocked;
export const isCompletionModalOpenSelector = state => export const isCompletionModalOpenSelector = state =>
state[ns].modal.completion; state[ns].modal.completion;
export const isHelpModalOpenSelector = state => state[ns].modal.help; 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 isJSEnabledSelector = state => state[ns].isJSEnabled;
export const successMessageSelector = state => state[ns].successMessage; 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 }) => ({ [types.initTests]: (state, { payload }) => ({
...state, ...state,
challengeTests: payload challengeTests: payload
@ -137,6 +158,7 @@ export const reducer = handleActions(
...state, ...state,
challengeTests: payload challengeTests: payload
}), }),
[types.initConsole]: (state, { payload }) => ({ [types.initConsole]: (state, { payload }) => ({
...state, ...state,
consoleOut: payload consoleOut: payload
@ -173,14 +195,21 @@ export const reducer = handleActions(
})), })),
consoleOut: '' consoleOut: ''
}), }),
[types.lockCode]: state => ({
...state,
isCodeLocked: true
}),
[types.unlockCode]: state => ({ [types.unlockCode]: state => ({
...state, ...state,
isJSEnabled: true isJSEnabled: true,
isCodeLocked: false
}), }),
[types.disableJSOnError]: state => ({ [types.disableJSOnError]: state => ({
...state, ...state,
isJSEnabled: false isJSEnabled: false
}), }),
[types.updateSuccessMessage]: (state, { payload }) => ({ [types.updateSuccessMessage]: (state, { payload }) => ({
...state, ...state,
successMessage: payload successMessage: payload

View File

@ -10044,6 +10044,10 @@ steno@^0.4.1:
dependencies: dependencies:
graceful-fs "^4.1.3" 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: stream-browserify@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"