diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts
index d1c9565fe2c..e7444fcb018 100644
--- a/client/src/redux/prop-types.ts
+++ b/client/src/redux/prop-types.ts
@@ -277,6 +277,7 @@ export type ChallengeMeta = {
block: string;
id: string;
introPath: string;
+ isFirstStep: boolean;
nextChallengePath: string;
prevChallengePath: string;
removeComments: boolean;
diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx
index 3909edd0854..b638801b8b3 100644
--- a/client/src/templates/Challenges/classic/desktop-layout.tsx
+++ b/client/src/templates/Challenges/classic/desktop-layout.tsx
@@ -1,6 +1,8 @@
import { first } from 'lodash-es';
-import React, { useState, ReactElement } from 'react';
+import React, { useState, useEffect, ReactElement } from 'react';
import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex';
+import { createSelector } from 'reselect';
+import { connect } from 'react-redux';
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
import { challengeTypes } from '../../../../utils/challenge-types';
import {
@@ -8,6 +10,13 @@ import {
ChallengeFiles,
ResizeProps
} from '../../../redux/prop-types';
+import { setShowPreviewPortal, setShowPreviewPane } from '../redux/actions';
+import {
+ portalWindowSelector,
+ showPreviewPortalSelector,
+ showPreviewPaneSelector,
+ isAdvancingToChallengeSelector
+} from '../redux/selectors';
import PreviewPortal from '../components/preview-portal';
import ActionRow from './action-row';
@@ -21,6 +30,8 @@ interface DesktopLayoutProps {
hasNotes: boolean;
hasPreview: boolean;
instructions: ReactElement;
+ isAdvancing: boolean;
+ isFirstStep: boolean;
layoutState: {
codePane: Pane;
editorPane: Pane;
@@ -34,16 +45,51 @@ interface DesktopLayoutProps {
resizeProps: ResizeProps;
testOutput: ReactElement;
windowTitle: string;
+ showPreviewPortal: boolean;
+ showPreviewPane: boolean;
+ setShowPreviewPortal: (arg: boolean) => void;
+ setShowPreviewPane: (arg: boolean) => void;
+ portalWindow: null | Window;
}
const reflexProps = {
propagateDimensions: true
};
+const mapDispatchToProps = {
+ setShowPreviewPortal,
+ setShowPreviewPane
+};
+
+const mapStateToProps = createSelector(
+ isAdvancingToChallengeSelector,
+ showPreviewPortalSelector,
+ showPreviewPaneSelector,
+ portalWindowSelector,
+
+ (
+ isAdvancing: boolean,
+ showPreviewPortal: boolean,
+ showPreviewPane: boolean,
+ portalWindow: null | Window
+ ) => ({
+ isAdvancing,
+ showPreviewPortal,
+ showPreviewPane,
+ portalWindow
+ })
+);
+
const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
+ const {
+ showPreviewPane,
+ showPreviewPortal,
+ setShowPreviewPane,
+ setShowPreviewPortal,
+ portalWindow
+ } = props;
+
const [showNotes, setShowNotes] = useState(false);
- const [showPreviewPane, setShowPreviewPane] = useState(true);
- const [showPreviewPortal, setShowPreviewPortal] = useState(false);
const [showConsole, setShowConsole] = useState(false);
const [showInstructions, setShowInstuctions] = useState(true);
@@ -52,10 +98,12 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
case 'showPreviewPane':
if (!showPreviewPane && showPreviewPortal) setShowPreviewPortal(false);
setShowPreviewPane(!showPreviewPane);
+ portalWindow?.close();
break;
case 'showPreviewPortal':
if (!showPreviewPortal && showPreviewPane) setShowPreviewPane(false);
setShowPreviewPortal(!showPreviewPortal);
+ if (showPreviewPortal) portalWindow?.close();
break;
case 'showConsole':
setShowConsole(!showConsole);
@@ -88,6 +136,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
testOutput,
hasNotes,
hasPreview,
+ isAdvancing,
+ isFirstStep,
layoutState,
notes,
preview,
@@ -95,6 +145,18 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
windowTitle
} = props;
+ // on mount
+ useEffect(() => {
+ if (isFirstStep) {
+ setShowPreviewPortal(false);
+ portalWindow?.close();
+ setShowPreviewPane(true);
+ } else if (!isAdvancing && !showPreviewPane && !showPreviewPortal) {
+ togglePane('showPreviewPane');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const challengeFile = getChallengeFile();
const projectBasedChallenge = hasEditableBoundaries;
const isMultifileCertProject =
@@ -197,9 +259,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
)}
{displayPreviewPortal && (
-
- {preview}
-
+ {preview}
)}
);
@@ -207,4 +267,4 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
DesktopLayout.displayName = 'DesktopLayout';
-export default DesktopLayout;
+export default connect(mapStateToProps, mapDispatchToProps)(DesktopLayout);
diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx
index 82605a74c79..c45f64a4564 100644
--- a/client/src/templates/Challenges/classic/show.tsx
+++ b/client/src/templates/Challenges/classic/show.tsx
@@ -46,7 +46,8 @@ import {
previewMounted,
updateChallengeMeta,
openModal,
- setEditorFocusability
+ setEditorFocusability,
+ setIsAdvancing
} from '../redux/actions';
import {
challengeFilesSelector,
@@ -84,7 +85,8 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
cancelTests,
previewMounted,
openModal,
- setEditorFocusability
+ setEditorFocusability,
+ setIsAdvancing
},
dispatch
);
@@ -113,6 +115,7 @@ interface ShowClassicProps {
updateChallengeMeta: (arg0: ChallengeMeta) => void;
openModal: (modal: string) => void;
setEditorFocusability: (canFocus: boolean) => void;
+ setIsAdvancing: (arg: boolean) => void;
previewMounted: () => void;
savedChallenges: CompletedChallenge[];
}
@@ -292,6 +295,7 @@ class ShowClassic extends Component {
initTests,
updateChallengeMeta,
openModal,
+ setIsAdvancing,
savedChallenges,
data: {
challengeNode: {
@@ -327,6 +331,7 @@ class ShowClassic extends Component {
helpCategory
});
challengeMounted(challengeMeta.id);
+ setIsAdvancing(false);
}
componentWillUnmount() {
@@ -480,7 +485,7 @@ class ShowClassic extends Component {
const {
executeChallenge,
pageContext: {
- challengeMeta: { nextChallengePath, prevChallengePath },
+ challengeMeta: { isFirstStep, nextChallengePath, prevChallengePath },
projectPreview: { challengeData, showProjectPreview }
},
challengeFiles,
@@ -539,6 +544,7 @@ class ShowClassic extends Component {
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
+ isFirstStep={isFirstStep}
layoutState={this.state.layout}
notes={this.renderNotes(notes)}
preview={this.renderPreview()}
diff --git a/client/src/templates/Challenges/components/Hotkeys.tsx b/client/src/templates/Challenges/components/Hotkeys.tsx
index a795b09ec30..a9d50cbcc73 100644
--- a/client/src/templates/Challenges/components/Hotkeys.tsx
+++ b/client/src/templates/Challenges/components/Hotkeys.tsx
@@ -4,12 +4,14 @@ import { HotKeys, GlobalHotKeys } from 'react-hotkeys';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { ChallengeFiles, Test, User } from '../../../redux/prop-types';
+import { isChallenge } from '../../../utils/path-parsers';
import { userSelector } from '../../../redux/selectors';
import {
setEditorFocusability,
submitChallenge,
- openModal
+ openModal,
+ setIsAdvancing
} from '../redux/actions';
import {
canFocusEditorSelector,
@@ -40,7 +42,8 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = {
setEditorFocusability,
submitChallenge,
- openShortcutsModal: () => openModal('shortcuts')
+ openShortcutsModal: () => openModal('shortcuts'),
+ setIsAdvancing
};
const keyMap = {
@@ -66,6 +69,7 @@ interface HotkeysProps {
nextChallengePath: string;
prevChallengePath: string;
setEditorFocusability: (arg0: boolean) => void;
+ setIsAdvancing: (arg0: boolean) => void;
tests: Test[];
usesMultifileEditor?: boolean;
openShortcutsModal: () => void;
@@ -83,6 +87,7 @@ function Hotkeys({
nextChallengePath,
prevChallengePath,
setEditorFocusability,
+ setIsAdvancing,
submitChallenge,
tests,
usesMultifileEditor,
@@ -130,10 +135,16 @@ function Hotkeys({
},
navigationMode: () => setEditorFocusability(false),
navigatePrev: () => {
- if (!canFocusEditor) void navigate(prevChallengePath);
+ if (!canFocusEditor) {
+ if (isChallenge(prevChallengePath)) setIsAdvancing(true);
+ void navigate(prevChallengePath);
+ }
},
navigateNext: () => {
- if (!canFocusEditor) void navigate(nextChallengePath);
+ if (!canFocusEditor) {
+ if (isChallenge(nextChallengePath)) setIsAdvancing(true);
+ void navigate(nextChallengePath);
+ }
},
showShortcuts: (e: React.KeyboardEvent) => {
if (!canFocusEditor && e.shiftKey && e.key === '?') {
diff --git a/client/src/templates/Challenges/components/preview-portal.tsx b/client/src/templates/Challenges/components/preview-portal.tsx
index 4710cf0dc8e..366fa68cff2 100644
--- a/client/src/templates/Challenges/components/preview-portal.tsx
+++ b/client/src/templates/Challenges/components/preview-portal.tsx
@@ -2,35 +2,60 @@ import { Component, ReactElement } from 'react';
import ReactDOM from 'react-dom';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
-import { storePortalDocument, removePortalDocument } from '../redux/actions';
+import { createSelector } from 'reselect';
+import {
+ storePortalWindow,
+ removePortalWindow,
+ setShowPreviewPortal,
+ setIsAdvancing
+} from '../redux/actions';
+import {
+ portalWindowSelector,
+ isAdvancingToChallengeSelector
+} from '../redux/selectors';
interface PreviewPortalProps {
children: ReactElement | null;
- togglePane: (pane: string) => void;
windowTitle: string;
t: TFunction;
- storePortalDocument: (document: Document | undefined) => void;
- removePortalDocument: () => void;
+ storePortalWindow: (window: Window | null) => void;
+ removePortalWindow: () => void;
+ portalWindow: null | Window;
+ setShowPreviewPortal: (arg: boolean) => void;
+ setIsAdvancing: (arg: boolean) => void;
+ isAdvancing: boolean;
}
const mapDispatchToProps = {
- storePortalDocument,
- removePortalDocument
+ storePortalWindow,
+ removePortalWindow,
+ setShowPreviewPortal,
+ setIsAdvancing
};
+const mapStateToProps = createSelector(
+ isAdvancingToChallengeSelector,
+ portalWindowSelector,
+ (isAdvancing: boolean, portalWindow: null | Window) => ({
+ isAdvancing,
+ portalWindow
+ })
+);
+
class PreviewPortal extends Component {
static displayName = 'PreviewPortal';
mainWindow: Window;
externalWindow: Window | null = null;
+ isAdvancing: boolean;
containerEl;
titleEl;
styleEl;
constructor(props: PreviewPortalProps) {
super(props);
-
this.mainWindow = window;
- this.externalWindow = null;
+ this.externalWindow = this.props.portalWindow;
+ this.isAdvancing = this.props.isAdvancing;
this.containerEl = document.createElement('div');
this.titleEl = document.createElement('title');
this.styleEl = document.createElement('style');
@@ -39,6 +64,17 @@ class PreviewPortal extends Component {
componentDidMount() {
const { t, windowTitle } = this.props;
+ if (!this.externalWindow) {
+ this.externalWindow = window.open(
+ '',
+ '',
+ 'width=960,height=540,left=100,top=100'
+ );
+ } else {
+ this.externalWindow.document.head.innerHTML = '';
+ this.externalWindow.document.body.innerHTML = '';
+ }
+
this.titleEl.innerText = `${t(
'learn.editor-tabs.preview'
)} | ${windowTitle}`;
@@ -51,12 +87,6 @@ class PreviewPortal extends Component {
}
`;
- this.externalWindow = window.open(
- '',
- '',
- 'width=960,height=540,left=100,top=100'
- );
-
this.externalWindow?.document.head.appendChild(this.titleEl);
this.externalWindow?.document.head.appendChild(this.styleEl);
this.externalWindow?.document.body.setAttribute(
@@ -69,19 +99,23 @@ class PreviewPortal extends Component {
);
this.externalWindow?.document.body.appendChild(this.containerEl);
this.externalWindow?.addEventListener('beforeunload', () => {
- this.props.togglePane('showPreviewPortal');
+ this.props.setShowPreviewPortal(false);
});
- this.props.storePortalDocument(this.externalWindow?.document);
+ this.props.storePortalWindow(this.externalWindow);
+ // close the portal if the main window closes
this.mainWindow?.addEventListener('beforeunload', () => {
this.externalWindow?.close();
});
}
componentWillUnmount() {
- this.externalWindow?.close();
- this.props.removePortalDocument();
+ if (!this.props.isAdvancing) {
+ this.externalWindow?.close();
+ }
+ this.props.removePortalWindow();
+ this.props.setIsAdvancing(false);
}
render() {
@@ -92,6 +126,6 @@ class PreviewPortal extends Component {
PreviewPortal.displayName = 'PreviewPortal';
export default connect(
- null,
+ mapStateToProps,
mapDispatchToProps
)(withTranslation()(PreviewPortal));
diff --git a/client/src/templates/Challenges/redux/action-types.js b/client/src/templates/Challenges/redux/action-types.js
index 9e2137bab75..328d38639d1 100644
--- a/client/src/templates/Challenges/redux/action-types.js
+++ b/client/src/templates/Challenges/redux/action-types.js
@@ -28,14 +28,16 @@ export const actionTypes = createTypes(
'storedCodeFound',
'noStoredCodeFound',
'saveEditorContent',
-
+ 'setShowPreviewPane',
+ 'setShowPreviewPortal',
'closeModal',
'openModal',
+ 'setIsAdvancing',
'previewMounted',
'projectPreviewMounted',
- 'storePortalDocument',
- 'removePortalDocument',
+ 'storePortalWindow',
+ 'removePortalWindow',
'challengeMounted',
'checkChallenge',
'executeChallenge',
diff --git a/client/src/templates/Challenges/redux/actions.js b/client/src/templates/Challenges/redux/actions.js
index 8c8577dd8a6..fa406cbb497 100644
--- a/client/src/templates/Challenges/redux/actions.js
+++ b/client/src/templates/Challenges/redux/actions.js
@@ -22,7 +22,6 @@ export const createQuestion = createAction(actionTypes.createQuestion);
export const initTests = createAction(actionTypes.initTests);
export const updateTests = createAction(actionTypes.updateTests);
export const cancelTests = createAction(actionTypes.cancelTests);
-
export const initConsole = createAction(actionTypes.initConsole);
export const initLogs = createAction(actionTypes.initLogs);
export const updateChallengeMeta = createAction(
@@ -37,6 +36,10 @@ export const updateSolutionFormValues = createAction(
export const updateSuccessMessage = createAction(
actionTypes.updateSuccessMessage
);
+export const setShowPreviewPortal = createAction(
+ actionTypes.setShowPreviewPortal
+);
+export const setShowPreviewPane = createAction(actionTypes.setShowPreviewPane);
export const logsToConsole = createAction(actionTypes.logsToConsole);
@@ -48,7 +51,7 @@ export const disableBuildOnError = createAction(
export const storedCodeFound = createAction(actionTypes.storedCodeFound);
export const noStoredCodeFound = createAction(actionTypes.noStoredCodeFound);
export const saveEditorContent = createAction(actionTypes.saveEditorContent);
-
+export const setIsAdvancing = createAction(actionTypes.setIsAdvancing);
export const closeModal = createAction(actionTypes.closeModal);
export const openModal = createAction(actionTypes.openModal);
@@ -57,12 +60,8 @@ export const projectPreviewMounted = createAction(
actionTypes.projectPreviewMounted
);
-export const storePortalDocument = createAction(
- actionTypes.storePortalDocument
-);
-export const removePortalDocument = createAction(
- actionTypes.removePortalDocument
-);
+export const storePortalWindow = createAction(actionTypes.storePortalWindow);
+export const removePortalWindow = createAction(actionTypes.removePortalWindow);
export const challengeMounted = createAction(actionTypes.challengeMounted);
export const checkChallenge = createAction(actionTypes.checkChallenge);
diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js
index c9314cf09a0..fb01406a690 100644
--- a/client/src/templates/Challenges/redux/completion-epic.js
+++ b/client/src/templates/Challenges/redux/completion-epic.js
@@ -3,7 +3,7 @@ import { omit } from 'lodash-es';
import { ofType } from 'redux-observable';
import { empty, of } from 'rxjs';
import { catchError, concat, retry, switchMap, tap } from 'rxjs/operators';
-
+import { isChallenge } from '../../../utils/path-parsers';
import { challengeTypes, submitTypes } from '../../../../utils/challenge-types';
import { actionTypes as submitActionTypes } from '../../../redux/action-types';
import {
@@ -16,7 +16,11 @@ import { mapFilesToChallengeFiles } from '../../../utils/ajax';
import { standardizeRequestBody } from '../../../utils/challenge-request-helpers';
import postUpdate$ from '../utils/post-update';
import { actionTypes } from './action-types';
-import { closeModal, updateSolutionFormValues } from './actions';
+import {
+ closeModal,
+ updateSolutionFormValues,
+ setIsAdvancing
+} from './actions';
import {
challengeFilesSelector,
challengeMetaSelector,
@@ -174,6 +178,7 @@ export default function completionEpic(action$, state$) {
};
return submitter(type, state).pipe(
+ concat(of(setIsAdvancing(isChallenge(pathToNavigateTo())))),
tap(res => {
if (res.type !== submitActionTypes.updateFailed) {
navigate(pathToNavigateTo());
diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js
index 8205211c393..e167a0c2eb4 100644
--- a/client/src/templates/Challenges/redux/index.js
+++ b/client/src/templates/Challenges/redux/index.js
@@ -41,9 +41,12 @@ const initialState = {
projectPreview: false,
shortcuts: false
},
- portalDocument: false,
+ portalWindow: null,
+ showPreviewPortal: false,
+ showPreviewPane: true,
projectFormValues: {},
- successMessage: 'Happy Coding!'
+ successMessage: 'Happy Coding!',
+ isAdvancing: false
};
export const epics = [
@@ -178,18 +181,30 @@ export const reducer = handleActions(
...state,
isBuildEnabled: false
}),
- [actionTypes.storePortalDocument]: (state, { payload }) => ({
+ [actionTypes.setShowPreviewPortal]: (state, { payload }) => ({
...state,
- portalDocument: payload
+ showPreviewPortal: payload
}),
- [actionTypes.removePortalDocument]: state => ({
+ [actionTypes.setShowPreviewPane]: (state, { payload }) => ({
...state,
- portalDocument: false
+ showPreviewPane: payload
+ }),
+ [actionTypes.storePortalWindow]: (state, { payload }) => ({
+ ...state,
+ portalWindow: payload
+ }),
+ [actionTypes.removePortalWindow]: state => ({
+ ...state,
+ portalWindow: null
}),
[actionTypes.updateSuccessMessage]: (state, { payload }) => ({
...state,
successMessage: payload
}),
+ [actionTypes.setIsAdvancing]: (state, { payload }) => ({
+ ...state,
+ isAdvancing: payload
+ }),
[actionTypes.closeModal]: (state, { payload }) => ({
...state,
modal: {
diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js
index 12d30ff54b4..251aca75638 100644
--- a/client/src/templates/Challenges/redux/selectors.js
+++ b/client/src/templates/Challenges/redux/selectors.js
@@ -29,8 +29,9 @@ export const successMessageSelector = state => state[ns].successMessage;
export const projectFormValuesSelector = state =>
state[ns].projectFormValues || {};
-
-export const portalDocumentSelector = state => state[ns].portalDocument;
+export const isAdvancingToChallengeSelector = state => state[ns].isAdvancing;
+export const portalDocumentSelector = state => state[ns].portalWindow?.document;
+export const portalWindowSelector = state => state[ns].portalWindow;
export const challengeDataSelector = state => {
const { challengeType } = challengeMetaSelector(state);
@@ -84,3 +85,5 @@ export const challengeDataSelector = state => {
export const attemptsSelector = state => state[ns].attempts;
export const canFocusEditorSelector = state => state[ns].canFocusEditor;
export const visibleEditorsSelector = state => state[ns].visibleEditors;
+export const showPreviewPortalSelector = state => state[ns].showPreviewPortal;
+export const showPreviewPaneSelector = state => state[ns].showPreviewPane;
diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js
index 13fdefe5116..09439cc9b24 100644
--- a/client/utils/gatsby/challenge-page-creator.js
+++ b/client/utils/gatsby/challenge-page-creator.js
@@ -48,6 +48,14 @@ const views = {
// quiz: Quiz
};
+function getIsFirstStep(_node, index, nodeArray) {
+ const current = nodeArray[index];
+ const previous = nodeArray[index - 1];
+
+ if (!previous) return true;
+ return previous.node.challenge.block !== current.node.challenge.block;
+}
+
function getNextChallengePath(_node, index, nodeArray) {
const next = nodeArray[index + 1];
return next ? next.node.challenge.fields.slug : '/learn';
@@ -85,6 +93,7 @@ exports.createChallengePages = function (createPage) {
certification,
superBlock,
block,
+ isFirstStep: getIsFirstStep(challenge, index, allChallengeEdges),
template,
required,
nextChallengePath: getNextChallengePath(