Files
freeCodeCamp/client/src/templates/Challenges/classic/show.tsx

556 lines
14 KiB
TypeScript
Raw Normal View History

import { graphql } from 'gatsby';
import React, { Component } from 'react';
2018-04-06 14:51:52 +01:00
import Helmet from 'react-helmet';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { HandlerProps } from 'react-reflex';
import Media from 'react-responsive';
import { bindActionCreators, Dispatch } from 'redux';
import { createStructuredSelector } from 'reselect';
import store from 'store';
import { challengeTypes } from '../../../../utils/challenge-types';
import LearnLayout from '../../../components/layouts/learn';
import {
ChallengeFile,
ChallengeFiles,
ChallengeMeta,
ChallengeNode,
ResizeProps,
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/ResetModal';
import ChallengeTitle from '../components/challenge-title';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import Notes from '../components/notes';
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';
2018-04-06 14:51:52 +01:00
import {
cancelTests,
2018-04-06 14:51:52 +01:00
challengeFilesSelector,
challengeMounted,
challengeTestsSelector,
consoleOutputSelector,
createFiles,
executeChallenge,
initConsole,
initTests,
isChallengeCompletedSelector,
previewMounted,
updateChallengeMeta,
openModal,
setEditorFocusability
} from '../redux';
import { getGuideUrl } from '../utils';
import MultifileEditor from './MultifileEditor';
import DesktopLayout from './desktop-layout';
import MobileLayout from './mobile-layout';
2018-04-06 14:51:52 +01:00
import './classic.css';
2018-09-21 08:33:19 +03:00
import '../components/test-frame.css';
2018-04-06 14:51:52 +01:00
// Redux Setup
const mapStateToProps = createStructuredSelector({
challengeFiles: challengeFilesSelector,
tests: challengeTestsSelector,
output: consoleOutputSelector,
isChallengeCompleted: isChallengeCompletedSelector
});
2018-04-06 14:51:52 +01:00
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
createFiles,
initConsole,
initTests,
updateChallengeMeta,
challengeMounted,
executeChallenge,
cancelTests,
previewMounted,
openModal,
setEditorFocusability
},
dispatch
);
2018-04-06 14:51:52 +01:00
// Types
interface ShowClassicProps {
cancelTests: () => void;
challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFile[]) => void;
data: { challengeNode: ChallengeNode };
executeChallenge: (options?: { showCompletionModal: boolean }) => void;
challengeFiles: ChallengeFiles;
initConsole: (arg0: string) => void;
initTests: (tests: Test[]) => void;
isChallengeCompleted: boolean;
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;
previewMounted: () => void;
}
interface ShowClassicState {
layout: ReflexLayout;
resizing: boolean;
}
interface ReflexLayout {
codePane: { flex: number };
editorPane: { flex: number };
instructionPane: { flex: number };
notesPane: { flex: number };
previewPane: { flex: number };
testsPane: { flex: number };
}
2018-04-06 14:51:52 +01:00
const MAX_MOBILE_WIDTH = 767;
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 }
};
// Component
class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
static displayName: string;
containerRef: React.RefObject<HTMLElement>;
editorRef: React.RefObject<HTMLElement>;
instructionsPanelRef: React.RefObject<HTMLDivElement>;
resizeProps: ResizeProps;
constructor(props: ShowClassicProps) {
super(props);
this.resizeProps = {
onStopResize: this.onStopResize.bind(this),
onResize: this.onResize.bind(this)
};
// layout: Holds the information of the panes sizes for desktop view
this.state = {
layout: this.getLayoutState(),
resizing: false
};
this.containerRef = React.createRef();
this.editorRef = React.createRef();
this.instructionsPanelRef = React.createRef();
}
getLayoutState(): ReflexLayout {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const reflexLayout: ReflexLayout = store.get(REFLEX_LAYOUT);
// Validate if user has not done any resize of the panes
if (!reflexLayout) return BASE_LAYOUT;
// 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 = isContained(
Object.keys(BASE_LAYOUT),
Object.keys(reflexLayout)
);
return isValidLayout ? reflexLayout : BASE_LAYOUT;
}
onResize() {
this.setState(state => ({ ...state, resizing: true }));
}
onStopResize(event: HandlerProps) {
// @ts-expect-error TODO: Apparently, name does not exist on type
const { name, flex } = event.component.props;
// Only interested in tracking layout updates for ReflexElement's
if (!name) {
this.setState(state => ({ ...state, resizing: false }));
return;
}
// Forcing a state update with the value of each panel since on stop resize
// is executed per each panel.
const newLayout =
typeof this.state.layout === 'object'
? {
...this.state.layout,
[name]: { flex }
}
: this.state.layout;
this.setState({
layout: newLayout,
resizing: false
});
store.set(REFLEX_LAYOUT, this.state.layout);
}
2018-04-06 14:51:52 +01:00
componentDidMount() {
const {
2018-05-24 19:45:38 +01:00
data: {
challengeNode: {
challenge: { title }
}
}
2018-04-06 14:51:52 +01:00
} = this.props;
this.initializeComponent(title);
2018-04-06 14:51:52 +01:00
}
componentDidUpdate(prevProps: ShowClassicProps) {
const {
data: {
challengeNode: {
challenge: {
title: prevTitle,
fields: { tests: prevTests }
}
}
}
} = prevProps;
const {
data: {
challengeNode: {
challenge: {
title: currentTitle,
fields: { tests: currTests }
}
}
}
} = this.props;
if (prevTitle !== currentTitle || prevTests !== currTests) {
this.initializeComponent(currentTitle);
}
}
initializeComponent(title: string) {
2018-04-06 15:45:49 +01:00
const {
challengeMounted,
2018-04-06 15:45:49 +01:00
createFiles,
initConsole,
2018-04-06 15:45:49 +01:00
initTests,
updateChallengeMeta,
openModal,
2018-04-06 15:45:49 +01:00
data: {
2018-05-24 19:45:38 +01:00
challengeNode: {
challenge: {
challengeFiles,
fields: { tests },
challengeType,
removeComments,
helpCategory
}
2018-05-24 19:45:38 +01:00
}
2018-04-06 15:45:49 +01:00
},
pageContext: {
challengeMeta,
projectPreview: { showProjectPreview }
}
2018-04-06 15:45:49 +01:00
} = this.props;
initConsole('');
createFiles(challengeFiles ?? []);
initTests(tests);
if (showProjectPreview) openModal('projectPreview');
updateChallengeMeta({
...challengeMeta,
title,
removeComments: removeComments !== false,
challengeType,
helpCategory
});
challengeMounted(challengeMeta.id);
2018-04-06 15:45:49 +01:00
}
componentWillUnmount() {
const { createFiles, cancelTests } = this.props;
createFiles([]);
cancelTests();
}
getChallenge = () => this.props.data.challengeNode.challenge;
getBlockNameTitle(t: TFunction) {
const { block, superBlock, title } = this.getChallenge();
return `${t(`intro:${superBlock}.blocks.${block}.title`)}: ${title}`;
}
getVideoUrl = () => this.getChallenge().videoUrl;
hasPreview() {
const { challengeType } = this.getChallenge();
return (
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multiFileCertProject
);
}
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
const {
block,
challengeType,
description,
forumTopicId,
instructions,
superBlock,
title,
translationPending
} = this.getChallenge();
const showBreadCrumbs =
challengeType !== challengeTypes.multiFileCertProject;
return (
<SidePanel
block={block}
challengeDescription={
<ChallengeDescription
block={block}
description={description}
instructions={instructions}
/>
}
challengeTitle={
<ChallengeTitle
block={block}
isCompleted={this.props.isChallengeCompleted}
showBreadCrumbs={showBreadCrumbs}
superBlock={superBlock}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
}
guideUrl={getGuideUrl({ forumTopicId, title })}
instructionsPanelRef={this.instructionsPanelRef}
showToolPanel={showToolPanel}
videoUrl={this.getVideoUrl()}
/>
);
}
renderEditor() {
const {
pageContext: {
projectPreview: { showProjectPreview }
},
challengeFiles,
data: {
challengeNode: {
challenge: {
fields: { tests },
usesMultifileEditor
}
}
}
} = this.props;
const { description, title } = this.getChallenge();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
2019-01-23 12:24:05 +03:00
return (
challengeFiles && (
2020-07-27 15:20:19 +02:00
<MultifileEditor
challengeFiles={challengeFiles}
containerRef={this.containerRef}
description={description}
2020-07-27 15:20:19 +02:00
editorRef={this.editorRef}
initialTests={tests}
2020-07-27 15:20:19 +02:00
resizeProps={this.resizeProps}
title={title}
usesMultifileEditor={usesMultifileEditor}
showProjectPreview={showProjectPreview}
2019-09-17 15:32:23 +02:00
/>
)
);
}
renderTestOutput() {
const { output, t } = this.props;
return (
<Output
2019-01-23 12:24:05 +03:00
defaultOutput={`
/**
* ${t('learn.test-output')}
*/
`}
output={output}
/>
);
}
renderNotes(notes?: string) {
return <Notes notes={notes} />;
}
renderPreview() {
2018-04-06 14:51:52 +01:00
return (
<Preview
className='full-height'
disableIframe={this.state.resizing}
previewMounted={this.props.previewMounted}
/>
);
}
render() {
const {
block,
challengeType,
fields: { blockName },
forumTopicId,
hasEditableBoundaries,
superBlock,
feat: enable new curriculum (#44183) * feat: use legacy flag chore: reorder challenges fix: linter revert: server change feat: unblock new editor fix: proper order fix: 0-based order fix: broke the order feat: move tribute certification to its own block feat: split the old projects block into 4 fix: put all blocks in order chore: add intro text refactor: use block, not blockName in query fix: project progress indicator * fix: reorder new challenges/certs * fix: reorder legacy challenges * fix: reintroduce legacy certs * feat: add showNewCurriculum flag to env * chore: forgot sample.env * feat: use feature flag for display * fix: rename meta + dirs to match new blocks * fix: add new blocks to help-category-map * fix: update completion-modal for new GQL schema * test: duplicate title/id errors -> warnings * fix: update completion-modal to new GQL schema Mk2 * chore: re-order metas (again) * fix: revert super-block-intro changes The intro needs to show both legacy and new content. We need to decide which pages are created, rather than than what a page shows when rendered. * feat: move upcoming curriculum into own superblock * fix: handle one certification with two superBlocks * fix: remove duplicated intros * fix: remove duplicate projects from /settings * fix: drop 'two' from Responsive Web Design Two * chore: rename slug suffix from two to v2 * feat: control display of new curriculum * feat: control project paths shown on /settings * fix: use new project order for /settings This does mean that /settings will change before the release, but I don't think it's serious. All the projects are there, just not in the legacy order. * fix: claim/show cert button * chore: remove isLegacy Since we have legacy superblocks, we don't currently need individual blocks to be legacy * test: fix utils.test * fix: verifyCanClaim needs certification If Shaun removes the cert claim cards, maybe we can remove this entirely * fix: add hasEditableBoundaries flags where needed * chore: remove isUpcomingChange * chore: v2 -> 22 Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
2021-12-20 10:36:31 -08:00
certification,
title,
usesMultifileEditor,
notes
} = this.getChallenge();
const {
executeChallenge,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath },
projectPreview
},
challengeFiles,
t
} = this.props;
return (
<Hotkeys
editorRef={this.editorRef}
executeChallenge={executeChallenge}
innerRef={this.containerRef}
instructionsPanelRef={this.instructionsPanelRef}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
usesMultifileEditor={usesMultifileEditor}
>
<LearnLayout>
<Helmet title={`${this.getBlockNameTitle(t)} | freeCodeCamp.org`} />
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor()}
guideUrl={getGuideUrl({ forumTopicId, title })}
hasEditableBoundaries={hasEditableBoundaries}
hasNotes={!!notes}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: false
})}
notes={this.renderNotes(notes)}
preview={this.renderPreview()}
testOutput={this.renderTestOutput()}
usesMultifileEditor={usesMultifileEditor}
videoUrl={this.getVideoUrl()}
/>
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
block={block}
challengeFiles={challengeFiles}
challengeType={challengeType}
editor={this.renderEditor()}
hasEditableBoundaries={hasEditableBoundaries}
hasNotes={!!notes}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
layoutState={this.state.layout}
notes={this.renderNotes(notes)}
preview={this.renderPreview()}
resizeProps={this.resizeProps}
superBlock={superBlock}
testOutput={this.renderTestOutput()}
/>
</Media>
<CompletionModal
block={block}
blockName={blockName}
feat: enable new curriculum (#44183) * feat: use legacy flag chore: reorder challenges fix: linter revert: server change feat: unblock new editor fix: proper order fix: 0-based order fix: broke the order feat: move tribute certification to its own block feat: split the old projects block into 4 fix: put all blocks in order chore: add intro text refactor: use block, not blockName in query fix: project progress indicator * fix: reorder new challenges/certs * fix: reorder legacy challenges * fix: reintroduce legacy certs * feat: add showNewCurriculum flag to env * chore: forgot sample.env * feat: use feature flag for display * fix: rename meta + dirs to match new blocks * fix: add new blocks to help-category-map * fix: update completion-modal for new GQL schema * test: duplicate title/id errors -> warnings * fix: update completion-modal to new GQL schema Mk2 * chore: re-order metas (again) * fix: revert super-block-intro changes The intro needs to show both legacy and new content. We need to decide which pages are created, rather than than what a page shows when rendered. * feat: move upcoming curriculum into own superblock * fix: handle one certification with two superBlocks * fix: remove duplicated intros * fix: remove duplicate projects from /settings * fix: drop 'two' from Responsive Web Design Two * chore: rename slug suffix from two to v2 * feat: control display of new curriculum * feat: control project paths shown on /settings * fix: use new project order for /settings This does mean that /settings will change before the release, but I don't think it's serious. All the projects are there, just not in the legacy order. * fix: claim/show cert button * chore: remove isLegacy Since we have legacy superblocks, we don't currently need individual blocks to be legacy * test: fix utils.test * fix: verifyCanClaim needs certification If Shaun removes the cert claim cards, maybe we can remove this entirely * fix: add hasEditableBoundaries flags where needed * chore: remove isUpcomingChange * chore: v2 -> 22 Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
2021-12-20 10:36:31 -08:00
certification={certification}
superBlock={superBlock}
/>
<HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
<ProjectPreviewModal previewConfig={projectPreview} />
</LearnLayout>
</Hotkeys>
2018-04-06 14:51:52 +01:00
);
}
}
ShowClassic.displayName = 'ShowClassic';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(ShowClassic));
2018-04-06 14:51:52 +01:00
export const query = graphql`
query ClassicChallenge($slug: String!) {
challengeNode(challenge: { fields: { slug: { eq: $slug } } }) {
challenge {
block
title
description
hasEditableBoundaries
instructions
notes
removeComments
challengeType
helpCategory
videoUrl
superBlock
feat: enable new curriculum (#44183) * feat: use legacy flag chore: reorder challenges fix: linter revert: server change feat: unblock new editor fix: proper order fix: 0-based order fix: broke the order feat: move tribute certification to its own block feat: split the old projects block into 4 fix: put all blocks in order chore: add intro text refactor: use block, not blockName in query fix: project progress indicator * fix: reorder new challenges/certs * fix: reorder legacy challenges * fix: reintroduce legacy certs * feat: add showNewCurriculum flag to env * chore: forgot sample.env * feat: use feature flag for display * fix: rename meta + dirs to match new blocks * fix: add new blocks to help-category-map * fix: update completion-modal for new GQL schema * test: duplicate title/id errors -> warnings * fix: update completion-modal to new GQL schema Mk2 * chore: re-order metas (again) * fix: revert super-block-intro changes The intro needs to show both legacy and new content. We need to decide which pages are created, rather than than what a page shows when rendered. * feat: move upcoming curriculum into own superblock * fix: handle one certification with two superBlocks * fix: remove duplicated intros * fix: remove duplicate projects from /settings * fix: drop 'two' from Responsive Web Design Two * chore: rename slug suffix from two to v2 * feat: control display of new curriculum * feat: control project paths shown on /settings * fix: use new project order for /settings This does mean that /settings will change before the release, but I don't think it's serious. All the projects are there, just not in the legacy order. * fix: claim/show cert button * chore: remove isLegacy Since we have legacy superblocks, we don't currently need individual blocks to be legacy * test: fix utils.test * fix: verifyCanClaim needs certification If Shaun removes the cert claim cards, maybe we can remove this entirely * fix: add hasEditableBoundaries flags where needed * chore: remove isUpcomingChange * chore: v2 -> 22 Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
2021-12-20 10:36:31 -08:00
certification
translationPending
forumTopicId
fields {
blockName
slug
tests {
text
testString
}
}
required {
link
src
}
usesMultifileEditor
challengeFiles {
fileKey
ext
name
contents
head
tail
editableRegionBoundaries
history
2018-04-06 14:51:52 +01:00
}
}
}
}
`;