mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
2906599bef
Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
633 lines
19 KiB
TypeScript
633 lines
19 KiB
TypeScript
import { graphql } from 'gatsby';
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import Helmet from 'react-helmet';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { connect } from 'react-redux';
|
|
import { HandlerProps } from 'react-reflex';
|
|
import { useMediaQuery } from 'react-responsive';
|
|
import { bindActionCreators, Dispatch } from 'redux';
|
|
import store from 'store';
|
|
import { editor } from 'monaco-editor';
|
|
import type { FitAddon } from 'xterm-addon-fit';
|
|
|
|
import { useFeature } from '@growthbook/growthbook-react';
|
|
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
|
|
import LearnLayout from '../../../components/layouts/learn';
|
|
import { MAX_MOBILE_WIDTH } from '../../../../config/misc';
|
|
|
|
import type {
|
|
ChallengeFiles,
|
|
ChallengeMeta,
|
|
ChallengeNode,
|
|
Hooks,
|
|
DailyCodingChallengeLanguages,
|
|
DailyCodingChallengeNode,
|
|
DailyCodingChallengePageContext,
|
|
PageContext,
|
|
ResizeProps,
|
|
SavedChallenge,
|
|
SavedChallengeFiles,
|
|
Test
|
|
} from '../../../redux/prop-types';
|
|
import { isContained } from '../../../utils/is-contained';
|
|
import ChallengeDescription from '../components/challenge-description';
|
|
import Hotkeys from '../components/hotkeys';
|
|
import ResetModal from '../components/reset-modal';
|
|
import ChallengeTitle from '../components/challenge-title';
|
|
import CompletionModal from '../components/completion-modal';
|
|
import HelpModal from '../components/help-modal';
|
|
import ShortcutsModal from '../components/shortcuts-modal';
|
|
import MobileAppModal from '../components/mobile-app-modal';
|
|
import Output from '../components/output';
|
|
import Preview, { type PreviewProps } from '../components/preview';
|
|
import ProjectPreviewModal from '../components/project-preview-modal';
|
|
import SidePanel from '../components/side-panel';
|
|
import VideoModal from '../components/video-modal';
|
|
import {
|
|
cancelTests,
|
|
challengeMounted,
|
|
createFiles,
|
|
executeChallenge,
|
|
initConsole,
|
|
initTests,
|
|
initHooks,
|
|
initVisibleEditors,
|
|
previewMounted,
|
|
updateChallengeMeta,
|
|
openModal,
|
|
setEditorFocusability,
|
|
setIsAdvancing
|
|
} from '../redux/actions';
|
|
import {
|
|
challengeFilesSelector,
|
|
consoleOutputSelector,
|
|
isChallengeCompletedSelector
|
|
} from '../redux/selectors';
|
|
import { savedChallengesSelector } from '../../../redux/selectors';
|
|
import { getGuideUrl } from '../utils';
|
|
import { preloadPage } from '../../../../utils/gatsby/page-loading';
|
|
import envData from '../../../../config/env.json';
|
|
import ToolPanel from '../components/tool-panel';
|
|
import { getChallengePaths } from '../utils/challenge-paths';
|
|
import { challengeHasPreview, isJavaScriptChallenge } from '../utils/build';
|
|
import { XtermTerminal } from './xterm';
|
|
import MultifileEditor from './multifile-editor';
|
|
import DesktopLayout from './desktop-layout';
|
|
import MobileLayout from './mobile-layout';
|
|
import { mergeChallengeFiles } from './saved-challenges';
|
|
|
|
import './classic.css';
|
|
import '../components/test-frame.css';
|
|
|
|
const mapStateToProps = (state: unknown) => ({
|
|
challengeFiles: challengeFilesSelector(state) as ChallengeFiles,
|
|
output: consoleOutputSelector(state) as string,
|
|
isChallengeCompleted: isChallengeCompletedSelector(state),
|
|
savedChallenges: savedChallengesSelector(state) as SavedChallenge[]
|
|
});
|
|
|
|
const mapDispatchToProps = (dispatch: Dispatch) =>
|
|
bindActionCreators(
|
|
{
|
|
createFiles,
|
|
initConsole,
|
|
initTests,
|
|
initHooks,
|
|
initVisibleEditors,
|
|
updateChallengeMeta,
|
|
challengeMounted,
|
|
executeChallenge,
|
|
cancelTests,
|
|
previewMounted,
|
|
openModal,
|
|
setEditorFocusability,
|
|
setIsAdvancing
|
|
},
|
|
dispatch
|
|
);
|
|
|
|
interface ShowClassicProps extends Pick<PreviewProps, 'previewMounted'> {
|
|
cancelTests: () => void;
|
|
challengeMounted: (arg0: string) => void;
|
|
createFiles: (arg0: ChallengeFiles | SavedChallengeFiles) => void;
|
|
dailyCodingChallengeLanguage: DailyCodingChallengeLanguages;
|
|
data: { challengeNode: ChallengeNode | DailyCodingChallengeNode };
|
|
executeChallenge: (options?: { showCompletionModal: boolean }) => void;
|
|
challengeFiles: ChallengeFiles;
|
|
initConsole: (arg0: string) => void;
|
|
initTests: (tests: Test[]) => void;
|
|
initHooks: (hooks?: Hooks) => void;
|
|
initVisibleEditors: () => void;
|
|
isChallengeCompleted: boolean;
|
|
isDailyCodingChallenge?: boolean;
|
|
output: string;
|
|
pageContext: PageContext | DailyCodingChallengePageContext;
|
|
updateChallengeMeta: (arg0: ChallengeMeta) => void;
|
|
openModal: (modal: string) => void;
|
|
setDailyCodingChallengeLanguage: (
|
|
language: DailyCodingChallengeLanguages
|
|
) => void;
|
|
setEditorFocusability: (canFocus: boolean) => void;
|
|
setIsAdvancing: (arg: boolean) => void;
|
|
savedChallenges: SavedChallenge[];
|
|
}
|
|
|
|
interface ReflexLayout {
|
|
codePane: { flex: number };
|
|
editorPane: { flex: number };
|
|
instructionPane: { flex: number };
|
|
notesPane: { flex: number };
|
|
previewPane: { flex: number };
|
|
testsPane: { flex: number };
|
|
}
|
|
|
|
interface RenderEditorArgs {
|
|
isMobileLayout: boolean;
|
|
isUsingKeyboardInTablist: boolean;
|
|
}
|
|
|
|
const REFLEX_LAYOUT = 'challenge-layout';
|
|
const BASE_LAYOUT = {
|
|
codePane: { flex: 1 },
|
|
editorPane: { flex: 1 },
|
|
instructionPane: { flex: 1 },
|
|
previewPane: { flex: 0.7 },
|
|
notesPane: { flex: 0.7 },
|
|
testsPane: { flex: 0.3 }
|
|
};
|
|
|
|
// Used to prevent monaco from stealing mouse/touch events on the upper jaw
|
|
// content widget so they can trigger their default actions. (Issue #46166)
|
|
const handleContentWidgetEvents = (e: MouseEvent | TouchEvent): void => {
|
|
const target = e.target as HTMLElement;
|
|
if (target?.closest('.editor-upper-jaw')) {
|
|
e.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const StepPreview = ({
|
|
dimensions,
|
|
disableIframe,
|
|
previewMounted,
|
|
challengeType,
|
|
xtermFitRef
|
|
}: Pick<PreviewProps, 'disableIframe' | 'previewMounted'> & {
|
|
challengeType: number;
|
|
xtermFitRef: React.MutableRefObject<FitAddon | null>;
|
|
dimensions?: { width: number; height: number };
|
|
}) => {
|
|
return challengeType === challengeTypes.python ||
|
|
challengeType === challengeTypes.multifilePythonCertProject ||
|
|
challengeType === challengeTypes.pyLab ||
|
|
challengeType === challengeTypes.dailyChallengePy ? (
|
|
<XtermTerminal dimensions={dimensions} xtermFitRef={xtermFitRef} />
|
|
) : (
|
|
<Preview disableIframe={disableIframe} previewMounted={previewMounted} />
|
|
);
|
|
};
|
|
|
|
// The newline is important, because this text ends up in a `pre` element.
|
|
const defaultOutput = `
|
|
/**
|
|
* Your test output will go here
|
|
*/`;
|
|
|
|
function ShowClassic({
|
|
challengeFiles,
|
|
data: {
|
|
challengeNode: {
|
|
challenge: {
|
|
challengeFiles: seedChallengeFiles,
|
|
block,
|
|
demoType,
|
|
title,
|
|
description,
|
|
instructions,
|
|
hooks,
|
|
tests,
|
|
challengeType,
|
|
hasEditableBoundaries = false,
|
|
superBlock,
|
|
helpCategory,
|
|
forumTopicId,
|
|
usesMultifileEditor,
|
|
notes,
|
|
videoUrl,
|
|
translationPending,
|
|
saveSubmissionToDB
|
|
}
|
|
}
|
|
},
|
|
pageContext: {
|
|
challengeMeta,
|
|
challengeMeta: { isFirstStep, nextChallengePath },
|
|
projectPreview: { challengeData }
|
|
},
|
|
createFiles,
|
|
cancelTests,
|
|
challengeMounted,
|
|
initConsole,
|
|
initTests,
|
|
initHooks,
|
|
initVisibleEditors,
|
|
dailyCodingChallengeLanguage,
|
|
isDailyCodingChallenge = false,
|
|
setDailyCodingChallengeLanguage,
|
|
updateChallengeMeta,
|
|
openModal,
|
|
setIsAdvancing,
|
|
savedChallenges,
|
|
isChallengeCompleted,
|
|
output,
|
|
executeChallenge,
|
|
previewMounted
|
|
}: ShowClassicProps) {
|
|
const { t } = useTranslation();
|
|
const [resizing, setResizing] = useState(false);
|
|
const [usingKeyboardInTablist, setUsingKeyboardInTablist] = useState(false);
|
|
const containerRef = useRef<HTMLElement>(null);
|
|
const editorRef = useRef<editor.IStandaloneCodeEditor>();
|
|
const instructionsPanelRef = useRef<HTMLDivElement>(null);
|
|
const xtermFitRef = useRef<FitAddon | null>(null);
|
|
const isMobile = useMediaQuery({
|
|
query: `(max-width: ${MAX_MOBILE_WIDTH}px)`
|
|
});
|
|
|
|
const guideUrl = getGuideUrl({ forumTopicId, title });
|
|
|
|
const blockNameTitle = `${t(
|
|
`intro:${superBlock}.blocks.${block}.title`
|
|
)}: ${title}`;
|
|
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
|
|
const openConsole = isJavaScriptChallenge({ challengeType });
|
|
const hasPreview = challengeHasPreview({ challengeType });
|
|
const getLayoutState = () => {
|
|
const reflexLayout = store.get(REFLEX_LAYOUT) as ReflexLayout | null;
|
|
|
|
// Check that the layout values stored are valid (exist in base layout). If
|
|
// not valid, it will fallback to the base layout values and be set on next
|
|
// user resize.
|
|
const isValidLayout =
|
|
reflexLayout &&
|
|
isContained(Object.keys(BASE_LAYOUT), Object.keys(reflexLayout));
|
|
|
|
if (!isValidLayout) store.remove(REFLEX_LAYOUT);
|
|
|
|
return isValidLayout ? reflexLayout : BASE_LAYOUT;
|
|
};
|
|
|
|
const onPreviewResize = () => xtermFitRef.current?.fit();
|
|
|
|
// layout: Holds the information of the panes sizes for desktop view
|
|
const [layout, setLayout] = useState(getLayoutState());
|
|
|
|
const onStopResize = (event: HandlerProps) => {
|
|
setResizing(false);
|
|
// 'name' is used to identify the Elements whose layout is stored.
|
|
const { name, flex } = event.component.props;
|
|
|
|
// onStopResize can be called multiple times before the state changes, so
|
|
// we need an updater function to ensure all updates are applied.
|
|
setLayout(l => {
|
|
const newLayout = name ? { ...l, [name]: { flex } } : l;
|
|
store.set(REFLEX_LAYOUT, newLayout);
|
|
return newLayout;
|
|
});
|
|
};
|
|
|
|
const setHtmlHeight = () => {
|
|
const vh = String(window.innerHeight - 1);
|
|
document.documentElement.style.height = vh + 'px';
|
|
};
|
|
const onResize = () => {
|
|
setResizing(true);
|
|
};
|
|
const resizeProps: ResizeProps = {
|
|
onResize,
|
|
onStopResize
|
|
};
|
|
|
|
const updateUsingKeyboardInTablist = (
|
|
usingKeyboardInTablist: boolean
|
|
): void => {
|
|
setUsingKeyboardInTablist(usingKeyboardInTablist);
|
|
};
|
|
|
|
// AB testing Pre-fetch in the Spanish locale
|
|
const isPreFetchEnabled = useFeature('prefetch_ab_test').on;
|
|
|
|
// Independent lower jaw is only enabled for desktop.
|
|
const showIndependentLowerJaw = !isMobile;
|
|
|
|
const showSidePanelTests = isMobile || !hasEditableBoundaries;
|
|
|
|
// Show test
|
|
|
|
useEffect(() => {
|
|
if (isPreFetchEnabled && envData.clientLocale === 'espanol') {
|
|
preloadPage(nextChallengePath);
|
|
}
|
|
}, [nextChallengePath, isPreFetchEnabled]);
|
|
|
|
useEffect(() => {
|
|
initializeComponent(title);
|
|
// Bug fix for the monaco content widget and touch devices/right mouse
|
|
// click. (Issue #46166)
|
|
document.addEventListener('mousedown', handleContentWidgetEvents, true);
|
|
document.addEventListener('contextmenu', handleContentWidgetEvents, true);
|
|
document.addEventListener('touchstart', handleContentWidgetEvents, true);
|
|
document.addEventListener('touchmove', handleContentWidgetEvents, true);
|
|
document.addEventListener('touchend', handleContentWidgetEvents, true);
|
|
|
|
window.addEventListener('resize', setHtmlHeight);
|
|
setHtmlHeight();
|
|
|
|
return () => {
|
|
createFiles([]);
|
|
cancelTests();
|
|
document.removeEventListener(
|
|
'mousedown',
|
|
handleContentWidgetEvents,
|
|
true
|
|
);
|
|
document.removeEventListener(
|
|
'contextmenu',
|
|
handleContentWidgetEvents,
|
|
true
|
|
);
|
|
document.removeEventListener(
|
|
'touchstart',
|
|
handleContentWidgetEvents,
|
|
true
|
|
);
|
|
document.removeEventListener(
|
|
'touchmove',
|
|
handleContentWidgetEvents,
|
|
true
|
|
);
|
|
document.removeEventListener('touchend', handleContentWidgetEvents, true);
|
|
window.removeEventListener('resize', setHtmlHeight);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [dailyCodingChallengeLanguage]);
|
|
|
|
const initializeComponent = (title: string): void => {
|
|
initConsole('');
|
|
|
|
const savedChallenge = savedChallenges?.find(challenge => {
|
|
return challenge.id === challengeMeta.id;
|
|
});
|
|
|
|
createFiles(
|
|
mergeChallengeFiles(seedChallengeFiles, savedChallenge?.challengeFiles)
|
|
);
|
|
|
|
initTests(tests);
|
|
initHooks(hooks);
|
|
|
|
initVisibleEditors();
|
|
|
|
// Typically, this kind of preview only appears on the first step of a
|
|
// project and is shown (once) automatically. In contrast, labs are more
|
|
// freeform, so the preview is shown on demand.
|
|
if (demoType === 'onLoad') openModal('projectPreview');
|
|
const challengePaths = getChallengePaths({
|
|
currentCurriculumPaths: challengeMeta
|
|
});
|
|
|
|
updateChallengeMeta({
|
|
...challengeMeta,
|
|
title,
|
|
challengeType,
|
|
helpCategory,
|
|
description,
|
|
...challengePaths
|
|
});
|
|
challengeMounted(challengeMeta.id);
|
|
setIsAdvancing(false);
|
|
};
|
|
|
|
const renderInstructionsPanel = ({
|
|
toolPanel,
|
|
hasDemo
|
|
}: {
|
|
toolPanel: React.ReactNode;
|
|
hasDemo: boolean;
|
|
}) => {
|
|
return (
|
|
<SidePanel
|
|
challengeDescription={
|
|
<ChallengeDescription
|
|
description={description}
|
|
instructions={instructions}
|
|
superBlock={superBlock}
|
|
/>
|
|
}
|
|
challengeTitle={
|
|
<ChallengeTitle
|
|
isCompleted={isChallengeCompleted}
|
|
translationPending={translationPending}
|
|
>
|
|
{title}
|
|
</ChallengeTitle>
|
|
}
|
|
instructionsPanelRef={instructionsPanelRef}
|
|
toolPanel={toolPanel}
|
|
hasDemo={hasDemo}
|
|
showSidePanelTests={showSidePanelTests}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderEditor = ({
|
|
isMobileLayout,
|
|
isUsingKeyboardInTablist
|
|
}: RenderEditorArgs) => {
|
|
return (
|
|
challengeFiles && (
|
|
<MultifileEditor
|
|
challengeFiles={challengeFiles}
|
|
block={block}
|
|
superBlock={superBlock}
|
|
containerRef={containerRef}
|
|
description={description}
|
|
editorRef={editorRef}
|
|
initialTests={tests}
|
|
isMobileLayout={isMobileLayout}
|
|
isUsingKeyboardInTablist={isUsingKeyboardInTablist}
|
|
resizeProps={resizeProps}
|
|
title={title}
|
|
usesMultifileEditor={usesMultifileEditor}
|
|
showProjectPreview={demoType === 'onLoad'}
|
|
showIndependentLowerJaw={showIndependentLowerJaw}
|
|
/>
|
|
)
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Hotkeys
|
|
challengeType={challengeType}
|
|
executeChallenge={executeChallenge}
|
|
containerRef={containerRef}
|
|
instructionsPanelRef={instructionsPanelRef}
|
|
usesMultifileEditor={usesMultifileEditor}
|
|
editorRef={editorRef}
|
|
>
|
|
<LearnLayout>
|
|
<Helmet title={windowTitle} />
|
|
{isMobile ? (
|
|
<MobileLayout
|
|
editor={renderEditor({
|
|
isMobileLayout: true,
|
|
isUsingKeyboardInTablist: usingKeyboardInTablist
|
|
})}
|
|
hasEditableBoundaries={hasEditableBoundaries}
|
|
hasPreview={hasPreview}
|
|
instructions={renderInstructionsPanel({
|
|
toolPanel: null,
|
|
hasDemo: demoType === 'onClick'
|
|
})}
|
|
notes={notes}
|
|
onPreviewResize={onPreviewResize}
|
|
preview={
|
|
<StepPreview
|
|
challengeType={challengeType}
|
|
disableIframe={resizing}
|
|
previewMounted={previewMounted}
|
|
xtermFitRef={xtermFitRef}
|
|
/>
|
|
}
|
|
windowTitle={windowTitle}
|
|
testOutput={
|
|
<Output defaultOutput={defaultOutput} output={output} />
|
|
}
|
|
toolPanel={
|
|
<ToolPanel guideUrl={guideUrl} isMobile videoUrl={videoUrl} />
|
|
}
|
|
updateUsingKeyboardInTablist={updateUsingKeyboardInTablist}
|
|
usesMultifileEditor={usesMultifileEditor}
|
|
/>
|
|
) : (
|
|
<DesktopLayout
|
|
challengeFiles={challengeFiles}
|
|
challengeType={challengeType}
|
|
editor={renderEditor({
|
|
isMobileLayout: false,
|
|
isUsingKeyboardInTablist: usingKeyboardInTablist
|
|
})}
|
|
hasEditableBoundaries={hasEditableBoundaries}
|
|
hasPreview={hasPreview}
|
|
instructions={renderInstructionsPanel({
|
|
toolPanel: null,
|
|
hasDemo: demoType === 'onClick'
|
|
})}
|
|
isDailyCodingChallenge={isDailyCodingChallenge}
|
|
dailyCodingChallengeLanguage={dailyCodingChallengeLanguage}
|
|
setDailyCodingChallengeLanguage={setDailyCodingChallengeLanguage}
|
|
isFirstStep={isFirstStep}
|
|
layoutState={layout}
|
|
notes={notes}
|
|
onPreviewResize={onPreviewResize}
|
|
preview={
|
|
<StepPreview
|
|
challengeType={challengeType}
|
|
disableIframe={resizing}
|
|
previewMounted={previewMounted}
|
|
xtermFitRef={xtermFitRef}
|
|
/>
|
|
}
|
|
resizeProps={resizeProps}
|
|
testOutput={
|
|
<Output defaultOutput={defaultOutput} output={output} />
|
|
}
|
|
windowTitle={windowTitle}
|
|
startWithConsoleShown={openConsole}
|
|
showIndependentLowerJaw={showIndependentLowerJaw}
|
|
/>
|
|
)}
|
|
<CompletionModal />
|
|
<HelpModal
|
|
challengeTitle={title}
|
|
challengeBlock={block}
|
|
superBlock={superBlock}
|
|
guideUrl={guideUrl}
|
|
videoUrl={videoUrl}
|
|
/>
|
|
<VideoModal videoUrl={videoUrl} />
|
|
<ResetModal
|
|
saveSubmissionToDB={saveSubmissionToDB}
|
|
challengeTitle={title}
|
|
/>
|
|
<ProjectPreviewModal
|
|
challengeData={challengeData}
|
|
closeText={t('buttons.start-coding')}
|
|
previewTitle={
|
|
demoType === 'onClick'
|
|
? t('learn.demo-project-title')
|
|
: t('learn.project-preview-title')
|
|
}
|
|
/>
|
|
<ShortcutsModal />
|
|
<MobileAppModal superBlock={superBlock} />
|
|
</LearnLayout>
|
|
</Hotkeys>
|
|
);
|
|
}
|
|
|
|
ShowClassic.displayName = 'ShowClassic';
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(ShowClassic);
|
|
|
|
export const query = graphql`
|
|
query ClassicChallenge($id: String!) {
|
|
challengeNode(id: { eq: $id }) {
|
|
challenge {
|
|
block
|
|
demoType
|
|
title
|
|
description
|
|
id
|
|
hasEditableBoundaries
|
|
instructions
|
|
notes
|
|
challengeType
|
|
helpCategory
|
|
videoUrl
|
|
superBlock
|
|
translationPending
|
|
forumTopicId
|
|
hooks {
|
|
beforeAll
|
|
beforeEach
|
|
afterEach
|
|
afterAll
|
|
}
|
|
fields {
|
|
slug
|
|
}
|
|
required {
|
|
link
|
|
src
|
|
}
|
|
usesMultifileEditor
|
|
challengeFiles {
|
|
fileKey
|
|
ext
|
|
name
|
|
contents
|
|
head
|
|
tail
|
|
editableRegionBoundaries
|
|
history
|
|
}
|
|
saveSubmissionToDB
|
|
tests {
|
|
text
|
|
testString
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|