未验证 提交 bb7893db 编写于 作者: O Oliver Eyton-Williams 提交者: GitHub

feat: show project preview (#43967)

* feat: add data for preview to challengeMeta

* feat: allow creation of project preview frames

* feat: make project preview data available for frame

* refactor: simplify reducer

* feat: show project preview for first challenge

* feat: show project preview on MultiFile challenges

* test: check for presence/absence of preview modal

* fix: simplify previewProject saga

* test: uncomment project preview test

* fix: increase modal size + change modal title

* modal-footer

* feat: adjust preview size

* fix: remove margin, padding, and line-height for preview of finished projects

* Revert "fix: remove margin, padding, and line-height for preview of finished projects"

This reverts commit 0db11a08191935bca9c76cd95fa629a1ba2c4ac5.

* fix: remove margin on all previews

* refactor: use closeModal('projectPreview') for clarity
Co-authored-by: NShaun Hamilton <shauhami020@gmail.com>

* fix: get started -> start coding!

* fix: update closeModal type
Co-authored-by: NmoT01 <20648924+moT01@users.noreply.github.com>
Co-authored-by: NAhmad Abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: NShaun Hamilton <shauhami020@gmail.com>
上级 a8b03327
......@@ -87,9 +87,21 @@ exports.createPages = function createPages({ graphql, actions, reporter }) {
src
}
challengeOrder
challengeFiles {
name
ext
contents
head
tail
}
solutions {
contents
ext
}
superBlock
superOrder
template
usesMultifileEditor
}
}
}
......
......@@ -62,7 +62,8 @@
"verify-email": "Verify Email",
"submit-and-go": "Submit and go to next challenge",
"go-to-next": "Go to next challenge",
"ask-later": "Ask me later"
"ask-later": "Ask me later",
"start-coding": "Start coding!"
},
"landing": {
"big-heading-1": "Learn to code — for free.",
......@@ -288,7 +289,8 @@
"preview": "Preview"
},
"help-translate": "We are still translating the following certifications.",
"help-translate-link": "Help us translate."
"help-translate-link": "Help us translate.",
"project-preview-title": "Here's a preview of what you will build"
},
"donate": {
"title": "Support our nonprofit",
......
......@@ -106,7 +106,7 @@ export type MarkdownRemark = {
type Question = { text: string; answers: string[]; solution: number };
type Fields = { slug: string; blockName: string; tests: Test[] };
type Required = {
export type Required = {
link: string;
raw: boolean;
src: string;
......
......@@ -28,6 +28,9 @@ import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import Output from '../components/output';
import Preview from '../components/preview';
import ProjectPreviewModal, {
PreviewConfig
} from '../components/project-preview-modal';
import SidePanel from '../components/side-panel';
import VideoModal from '../components/video-modal';
import {
......@@ -41,7 +44,10 @@ import {
initConsole,
initTests,
isChallengeCompletedSelector,
updateChallengeMeta
previewMounted,
updateChallengeMeta,
openModal,
setEditorFocusability
} from '../redux';
import { getGuideUrl } from '../utils';
import MultifileEditor from './MultifileEditor';
......@@ -68,7 +74,10 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
updateChallengeMeta,
challengeMounted,
executeChallenge,
cancelTests
cancelTests,
previewMounted,
openModal,
setEditorFocusability
},
dispatch
);
......@@ -87,10 +96,13 @@ interface ShowClassicProps {
output: string[];
pageContext: {
challengeMeta: ChallengeMeta;
projectPreview: PreviewConfig & { showProjectPreview: boolean };
};
t: TFunction;
tests: Test[];
updateChallengeMeta: (arg0: ChallengeMeta) => void;
openModal: (modal: string) => void;
setEditorFocusability: (canFocus: boolean) => void;
}
interface ShowClassicState {
......@@ -231,6 +243,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
initConsole,
initTests,
updateChallengeMeta,
openModal,
data: {
challengeNode: {
challengeFiles,
......@@ -240,11 +253,15 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
helpCategory
}
},
pageContext: { challengeMeta }
pageContext: {
challengeMeta,
projectPreview: { showProjectPreview }
}
} = this.props;
initConsole('');
createFiles(challengeFiles ?? []);
initTests(tests);
if (showProjectPreview) openModal('projectPreview');
updateChallengeMeta({
...challengeMeta,
title,
......@@ -358,7 +375,11 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
renderPreview() {
return (
<Preview className='full-height' disableIframe={this.state.resizing} />
<Preview
className='full-height'
disableIframe={this.state.resizing}
previewMounted={previewMounted}
/>
);
}
......@@ -383,7 +404,8 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
const {
executeChallenge,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
challengeMeta: { nextChallengePath, prevChallengePath },
projectPreview
},
challengeFiles,
t
......@@ -443,6 +465,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
<HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
<ProjectPreviewModal previewConfig={projectPreview} />
</LearnLayout>
</Hotkeys>
);
......@@ -456,9 +479,6 @@ export default connect(
mapDispatchToProps
)(withTranslation()(ShowClassic));
// TODO: handle jsx (not sure why it doesn't get an editableRegion) EDIT:
// probably because the dummy challenge didn't include it, so Gatsby couldn't
// infer it.
export const query = graphql`
query ClassicChallenge($slug: String!) {
challengeNode(fields: { slug: { eq: $slug } }) {
......
.challenge-preview,
.challenge-preview-frame {
height: 100%;
min-height: 70vh;
width: 100%;
padding: 0;
margin: 0;
......
import React, { useState, useEffect } from 'react';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { useTranslation } from 'react-i18next';
import { previewMounted } from '../redux';
import { mainPreviewId } from '../utils/frame';
import './preview.css';
const mainId = 'fcc-main-frame';
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
previewMounted
},
dispatch
);
interface PreviewProps {
className?: string;
disableIframe?: boolean;
previewMounted: () => void;
t: (text: string) => string;
previewId?: string;
}
function Preview({ disableIframe, previewMounted, t }: PreviewProps) {
function Preview({
disableIframe,
previewMounted,
previewId
}: PreviewProps): JSX.Element {
const { t } = useTranslation();
const [iframeStatus, setIframeStatus] = useState<boolean | undefined>(false);
const iframeToggle = iframeStatus ? 'disable' : 'enable';
......@@ -36,11 +29,14 @@ function Preview({ disableIframe, previewMounted, t }: PreviewProps) {
setIframeStatus(disableIframe);
}, [disableIframe]);
// TODO: remove type assertion once frame.js has been migrated.
const id: string = previewId ?? (mainPreviewId as string);
return (
<div className={`notranslate challenge-preview ${iframeToggle}-iframe`}>
<iframe
className={'challenge-preview-frame'}
id={mainId}
id={id}
title={t('learn.chal-preview')}
/>
</div>
......@@ -49,4 +45,4 @@ function Preview({ disableIframe, previewMounted, t }: PreviewProps) {
Preview.displayName = 'Preview';
export default connect(null, mapDispatchToProps)(withTranslation()(Preview));
export default Preview;
.project-preview-modal-body {
line-height: 0;
padding: 0;
}
import { Button, Modal } from '@freecodecamp/react-bootstrap';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import type { ChallengeFile, Required } from '../../../redux/prop-types';
import {
closeModal,
setEditorFocusability,
isProjectPreviewModalOpenSelector,
projectPreviewMounted
} from '../redux';
import { projectPreviewId } from '../utils/frame';
import Preview from './preview';
import './project-preview-modal.css';
export interface PreviewConfig {
challengeType: boolean;
challengeFiles: ChallengeFile[];
required: Required;
template: string;
}
interface Props {
closeModal: (arg: string) => void;
isOpen: boolean;
projectPreviewMounted: (previewConfig: PreviewConfig) => void;
previewConfig: PreviewConfig;
setEditorFocusability: (focusability: boolean) => void;
}
const mapStateToProps = (state: unknown) => ({
isOpen: isProjectPreviewModalOpenSelector(state) as boolean
});
const mapDispatchToProps = {
closeModal,
setEditorFocusability,
projectPreviewMounted
};
export function ProjectPreviewModal({
closeModal,
isOpen,
projectPreviewMounted,
previewConfig,
setEditorFocusability
}: Props): JSX.Element {
const { t } = useTranslation();
useEffect(() => {
if (isOpen) setEditorFocusability(false);
});
return (
<Modal
bsSize='lg'
data-cy='project-preview-modal'
dialogClassName='project-preview-modal'
onHide={() => {
closeModal('projectPreview');
setEditorFocusability(true);
}}
show={isOpen}
>
<Modal.Header
className='project-preview-modal-header fcc-modal'
closeButton={true}
>
<Modal.Title className='text-center'>
{t('learn.project-preview-title')}
</Modal.Title>
</Modal.Header>
<Modal.Body className='project-preview-modal-body text-center'>
{/* remove type assertion once frame.js has been migrated to TS */}
<Preview
previewId={projectPreviewId as string}
previewMounted={() => {
projectPreviewMounted(previewConfig);
}}
/>
</Modal.Body>
<Modal.Footer>
<Button
block={true}
bsSize='lg'
bsStyle='primary'
onClick={() => {
closeModal('projectPreview');
setEditorFocusability(true);
}}
>
{t('buttons.start-coding')}
</Button>
</Modal.Footer>
</Modal>
);
}
ProjectPreviewModal.displayName = 'ProjectPreviewModal';
export default connect(
mapStateToProps,
mapDispatchToProps
)(ProjectPreviewModal);
......@@ -20,7 +20,7 @@ const cssCatch = '\n/*fcc*/\n';
const defaultTemplate = ({ source }) => {
return `
<body id='display-body'style='margin:8px;'>
<body id='display-body'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
......
......@@ -32,6 +32,7 @@ export const actionTypes = createTypes(
'openModal',
'previewMounted',
'projectPreviewMounted',
'challengeMounted',
'checkChallenge',
'executeChallenge',
......
......@@ -21,6 +21,7 @@ import {
getTestRunner,
challengeHasPreview,
updatePreview,
updateProjectPreview,
isJavaScriptChallenge,
isLoopProtected
} from '../utils/build';
......@@ -240,6 +241,24 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
}
}
function* previewProjectSolutionSaga({ payload }) {
if (!payload) return;
const { showProjectPreview, challengeData } = payload;
if (!showProjectPreview) return;
try {
if (canBuildChallenge(challengeData)) {
const buildData = yield buildChallengeData(challengeData);
if (challengeHasPreview(challengeData)) {
const document = yield getContext('document');
yield call(updateProjectPreview, buildData, document);
}
}
} catch (err) {
console.log(err);
}
}
export function createExecuteChallengeSaga(types) {
return [
takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
......@@ -251,6 +270,7 @@ export function createExecuteChallengeSaga(types) {
types.resetChallenge
],
executeCancellablePreviewSaga
)
),
takeLatest(types.projectPreviewMounted, previewProjectSolutionSaga)
];
}
......@@ -39,7 +39,8 @@ const initialState = {
completion: false,
help: false,
video: false,
reset: false
reset: false,
projectPreview: false
},
projectFormValues: {},
successMessage: 'Happy Coding!'
......@@ -61,21 +62,16 @@ export const sagas = [
export const createFiles = createAction(
actionTypes.createFiles,
challengeFiles =>
challengeFiles.reduce((challengeFiles, challengeFile) => {
return [
...challengeFiles,
{
...createPoly(challengeFile),
seed: challengeFile.contents.slice(),
editableContents: getLines(
challengeFile.contents,
challengeFile.editableRegionBoundaries
),
seedEditableRegionBoundaries:
challengeFile.editableRegionBoundaries.slice()
}
];
}, [])
challengeFiles.map(challengeFile => ({
...createPoly(challengeFile),
seed: challengeFile.contents.slice(),
editableContents: getLines(
challengeFile.contents,
challengeFile.editableRegionBoundaries
),
seedEditableRegionBoundaries:
challengeFile.editableRegionBoundaries.slice()
}))
);
export const createQuestion = createAction(actionTypes.createQuestion);
......@@ -114,6 +110,9 @@ export const closeModal = createAction(actionTypes.closeModal);
export const openModal = createAction(actionTypes.openModal);
export const previewMounted = createAction(actionTypes.previewMounted);
export const projectPreviewMounted = createAction(
actionTypes.projectPreviewMounted
);
export const challengeMounted = createAction(actionTypes.challengeMounted);
export const checkChallenge = createAction(actionTypes.checkChallenge);
export const executeChallenge = createAction(actionTypes.executeChallenge);
......@@ -148,6 +147,8 @@ export const isCompletionModalOpenSelector = state =>
export const isHelpModalOpenSelector = state => state[ns].modal.help;
export const isVideoModalOpenSelector = state => state[ns].modal.video;
export const isResetModalOpenSelector = state => state[ns].modal.reset;
export const isProjectPreviewModalOpenSelector = state =>
state[ns].modal.projectPreview;
export const isResettingSelector = state => state[ns].isResetting;
export const isBuildEnabledSelector = state => state[ns].isBuildEnabled;
......
......@@ -9,7 +9,8 @@ import { getTransformers } from '../rechallenge/transformers';
import {
createTestFramer,
runTestInTestFrame,
createMainFramer
createMainPreviewFramer,
createProjectPreviewFramer
} from './frame';
import createWorker from './worker-executor';
......@@ -138,7 +139,7 @@ function getJSTestRunner({ build, sources }, { proxyLogger, removeComments }) {
async function getDOMTestRunner(buildData, { proxyLogger }, document) {
await new Promise(resolve =>
createTestFramer(document, resolve, proxyLogger)(buildData)
createTestFramer(document, proxyLogger, resolve)(buildData)
);
return (testString, testTimeout) =>
runTestInTestFrame(document, testString, testTimeout);
......@@ -197,15 +198,23 @@ export function buildBackendChallenge({ url }) {
};
}
export async function updatePreview(buildData, document, proxyLogger) {
const { challengeType } = buildData;
if (challengeType === challengeTypes.html) {
await new Promise(resolve =>
createMainFramer(document, resolve, proxyLogger)(buildData)
export function updatePreview(buildData, document, proxyLogger) {
if (buildData.challengeType === challengeTypes.html) {
createMainPreviewFramer(document, proxyLogger)(buildData);
} else {
throw new Error(
`Cannot show preview for challenge type ${buildData.challengeType}`
);
}
}
export function updateProjectPreview(buildData, document) {
if (buildData.challengeType === challengeTypes.html) {
createProjectPreviewFramer(document)(buildData);
} else {
throw new Error(`Cannot show preview for challenge type ${challengeType}`);
throw new Error(
`Cannot show preview for challenge type ${buildData.challengeType}`
);
}
}
......
......@@ -3,9 +3,11 @@ import { format } from '../../../utils/format';
// we use two different frames to make them all essentially pure functions
// main iframe is responsible rendering the preview and is where we proxy the
const mainId = 'fcc-main-frame';
export const mainPreviewId = 'fcc-main-frame';
// the test frame is responsible for running the assert tests
const testId = 'fcc-test-frame';
// the project preview frame demos the finished project
export const projectPreviewId = 'fcc-project-preview-frame';
// base tag here will force relative links
// within iframe to point to '' instead of
......@@ -16,7 +18,7 @@ const testId = 'fcc-test-frame';
// window.onerror is added here to report any errors thrown during the building
// of the frame. React dom errors already appear in the console, so onerror
// does not need to pass them on to the default error handler.
const createHeader = (id = mainId) => `
const createHeader = (id = mainPreviewId) => `
<base href='' />
<script>
window.__frameId = '${id}';
......@@ -68,13 +70,15 @@ const createFrame = (document, id) => ctx => {
const hiddenFrameClassName = 'hide-test-frame';
const mountFrame =
document =>
(document, id) =>
({ element, ...rest }) => {
const oldFrame = document.getElementById(element.id);
if (oldFrame) {
element.className = oldFrame.className || hiddenFrameClassName;
oldFrame.parentNode.replaceChild(element, oldFrame);
} else {
// only test frames can be added (and hidden) here, other frames must be
// added by react
} else if (id === testId) {
element.className = hiddenFrameClassName;
document.body.appendChild(element);
}
......@@ -87,11 +91,13 @@ const mountFrame =
};
const buildProxyConsole = proxyLogger => ctx => {
const oldLog = ctx.window.console.log.bind(ctx.window.console);
ctx.window.console.log = function proxyConsole(...args) {
proxyLogger(args.map(arg => format(arg)).join(' '));
return oldLog(...args);
};
if (proxyLogger) {
const oldLog = ctx.window.console.log.bind(ctx.window.console);
ctx.window.console.log = function proxyConsole(...args) {
proxyLogger(args.map(arg => format(arg)).join(' '));
return oldLog(...args);
};
}
return ctx;
};
......@@ -112,7 +118,7 @@ const initTestFrame = frameReady => ctx => {
return ctx;
};
const initMainFrame = (frameReady, proxyLogger) => ctx => {
const initMainFrame = (_, proxyLogger) => ctx => {
waitForFrame(ctx).then(() => {
// Overwriting the onerror added by createHeader to catch any errors thrown
// after the frame is ready. It has to be overwritten, as proxyLogger cannot
......@@ -129,11 +135,12 @@ const initMainFrame = (frameReady, proxyLogger) => ctx => {
// an error from a cross origin script just appears as 'Script error.'
return false;
};
frameReady();
});
return ctx;
};
const initPreviewFrame = () => ctx => ctx;
const waitForFrame = ctx => {
return new Promise(resolve => {
if (ctx.document.readyState === 'loading') {
......@@ -156,16 +163,19 @@ const writeContentToFrame = ctx => {
return ctx;
};
export const createMainFramer = (document, frameReady, proxyLogger) =>
createFramer(document, frameReady, proxyLogger, mainId, initMainFrame);
export const createMainPreviewFramer = (document, proxyLogger) =>
createFramer(document, mainPreviewId, initMainFrame, proxyLogger);
export const createProjectPreviewFramer = document =>
createFramer(document, projectPreviewId, initPreviewFrame);
export const createTestFramer = (document, frameReady, proxyLogger) =>
createFramer(document, frameReady, proxyLogger, testId, initTestFrame);
export const createTestFramer = (document, proxyLogger, frameReady) =>
createFramer(document, testId, initTestFrame, proxyLogger, frameReady);
const createFramer = (document, frameReady, proxyLogger, id, init) =>
const createFramer = (document, id, init, proxyLogger, frameReady) =>
flow(
createFrame(document, id),
mountFrame(document),