Merge pull request #38 from Bouncey/feat/codeStorage
Feat: Add code storage and lesson reset modal
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
68eefa5565
commit
bb6b3869ed
@ -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"
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -82,7 +82,6 @@ class Editor extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { contents, ext } = this.props;
|
||||
|
||||
return (
|
||||
<div className='classic-editor editor'>
|
||||
<base href='/' />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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);
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user