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(