diff --git a/packages/learn/package.json b/packages/learn/package.json index f99d3eb3b6..5b625c7056 100644 --- a/packages/learn/package.json +++ b/packages/learn/package.json @@ -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" }, diff --git a/packages/learn/src/auth/index.js b/packages/learn/src/auth/index.js index d82ee473d4..8139940652 100644 --- a/packages/learn/src/auth/index.js +++ b/packages/learn/src/auth/index.js @@ -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); diff --git a/packages/learn/src/templates/Challenges/classic/Editor.js b/packages/learn/src/templates/Challenges/classic/Editor.js index 38f335a30a..13f4989f00 100644 --- a/packages/learn/src/templates/Challenges/classic/Editor.js +++ b/packages/learn/src/templates/Challenges/classic/Editor.js @@ -82,7 +82,6 @@ class Editor extends PureComponent { render() { const { contents, ext } = this.props; - return (
diff --git a/packages/learn/src/templates/Challenges/classic/Show.js b/packages/learn/src/templates/Challenges/classic/Show.js index badaaf5317..195fb68bf6 100644 --- a/packages/learn/src/templates/Challenges/classic/Show.js +++ b/packages/learn/src/templates/Challenges/classic/Show.js @@ -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 { + ); } diff --git a/packages/learn/src/templates/Challenges/components/CompletionModal.js b/packages/learn/src/templates/Challenges/components/CompletionModal.js index 55ffb0a7a6..031daf7a7a 100644 --- a/packages/learn/src/templates/Challenges/components/CompletionModal.js +++ b/packages/learn/src/templates/Challenges/components/CompletionModal.js @@ -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()); } }; diff --git a/packages/learn/src/templates/Challenges/components/ResetModal.js b/packages/learn/src/templates/Challenges/components/ResetModal.js new file mode 100644 index 0000000000..791a1942a9 --- /dev/null +++ b/packages/learn/src/templates/Challenges/components/ResetModal.js @@ -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 ( + + + Reset this lesson? + + +
+

+ Are you sure you wish to reset this lesson? The editors and tests + will be reset. +

+

+ This cannot be undone. +

+
+
+ + + +
+ ); +} + +ResetModal.displayName = 'ResetModal'; +ResetModal.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(ResetModal); diff --git a/packages/learn/src/templates/Challenges/components/Side-Panel.js b/packages/learn/src/templates/Challenges/components/Side-Panel.js index 336461a57d..1756c4ecf5 100644 --- a/packages/learn/src/templates/Challenges/components/Side-Panel.js +++ b/packages/learn/src/templates/Challenges/components/Side-Panel.js @@ -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} /> diff --git a/packages/learn/src/templates/Challenges/redux/code-storage-epic.js b/packages/learn/src/templates/Challenges/redux/code-storage-epic.js new file mode 100644 index 0000000000..deaccf391c --- /dev/null +++ b/packages/learn/src/templates/Challenges/redux/code-storage-epic.js @@ -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); diff --git a/packages/learn/src/templates/Challenges/redux/create-question-epic.js b/packages/learn/src/templates/Challenges/redux/create-question-epic.js index 25ce9b1d78..eddef56dc1 100644 --- a/packages/learn/src/templates/Challenges/redux/create-question-epic.js +++ b/packages/learn/src/templates/Challenges/redux/create-question-epic.js @@ -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; diff --git a/packages/learn/src/templates/Challenges/redux/index.js b/packages/learn/src/templates/Challenges/redux/index.js index d712c90b1a..3afb776ab1 100644 --- a/packages/learn/src/templates/Challenges/redux/index.js +++ b/packages/learn/src/templates/Challenges/redux/index.js @@ -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 diff --git a/packages/learn/yarn.lock b/packages/learn/yarn.lock index 8c72d826ba..bfb8d63f03 100644 --- a/packages/learn/yarn.lock +++ b/packages/learn/yarn.lock @@ -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"