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

491 lines
12 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
// Package Utilities
import React, { Component } from 'react';
import { bindActionCreators, Dispatch } from 'redux';
import { createStructuredSelector } from 'reselect';
2018-04-06 14:51:52 +01:00
import { connect } from 'react-redux';
import Helmet from 'react-helmet';
2018-09-11 16:10:21 +03:00
import { graphql } from 'gatsby';
import Media from 'react-responsive';
import { TFunction, withTranslation } from 'react-i18next';
2018-04-06 14:51:52 +01:00
// Local Utilities
import LearnLayout from '../../../components/layouts/learn';
2020-07-27 15:20:19 +02:00
import MultifileEditor from './MultifileEditor';
2018-04-06 14:51:52 +01:00
import Preview from '../components/Preview';
import SidePanel from '../components/Side-Panel';
import Output from '../components/output';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/HelpModal';
import VideoModal from '../components/VideoModal';
import ResetModal from '../components/ResetModal';
import MobileLayout from './MobileLayout';
import DesktopLayout from './DesktopLayout';
import Hotkeys from '../components/Hotkeys';
import { getGuideUrl } from '../utils';
import store from 'store';
import { challengeTypes } from '../../../../utils/challengeTypes';
import { isContained } from '../../../utils/is-contained';
import {
ChallengeNodeType,
ChallengeFileType,
ChallengeMetaType,
TestType,
ResizePropsType
} from '../../../redux/prop-types';
2018-04-06 14:51:52 +01:00
import {
createFiles,
challengeFilesSelector,
challengeTestsSelector,
initConsole,
2018-04-06 14:51:52 +01:00
initTests,
updateChallengeMeta,
challengeMounted,
consoleOutputSelector,
executeChallenge,
cancelTests
} from '../redux';
2018-04-06 14:51:52 +01:00
// Styles
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({
files: challengeFilesSelector,
tests: challengeTestsSelector,
output: consoleOutputSelector
});
2018-04-06 14:51:52 +01:00
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
createFiles,
initConsole,
initTests,
updateChallengeMeta,
challengeMounted,
executeChallenge,
cancelTests
},
dispatch
);
2018-04-06 14:51:52 +01:00
// Types
interface ShowClassicProps {
cancelTests: () => void;
challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFileType) => void;
data: { challengeNode: ChallengeNodeType };
executeChallenge: () => void;
files: ChallengeFileType;
initConsole: (arg0: string) => void;
initTests: (tests: TestType[]) => void;
output: string[];
pageContext: {
challengeMeta: ChallengeMetaType;
};
t: TFunction;
tests: TestType[];
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
}
interface ShowClassicState {
resizing: boolean;
}
interface IReflexLayout {
codePane: { flex: number };
editorPane: { flex: number };
instructionPane: { 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 },
testsPane: { flex: 0.25 }
};
// Component
class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
static displayName: string;
containerRef: React.RefObject<unknown>;
editorRef: React.RefObject<unknown>;
instructionsPanelRef: React.RefObject<HTMLElement>;
resizeProps: ResizePropsType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layoutState: any;
constructor(props: ShowClassicProps) {
super(props);
this.resizeProps = {
onStopResize: this.onStopResize.bind(this),
onResize: this.onResize.bind(this)
};
this.state = {
resizing: false
};
this.containerRef = React.createRef();
this.editorRef = React.createRef();
this.instructionsPanelRef = React.createRef();
// Holds the information of the panes sizes for desktop view
this.layoutState = this.getLayoutState();
}
getLayoutState(): IReflexLayout | string {
const reflexLayout: IReflexLayout | string = 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({ resizing: true });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStopResize(event: any) {
const { name, flex } = event.component.props;
this.setState({ resizing: false });
// Only interested in tracking layout updates for ReflexElement's
if (!name) {
return;
}
this.layoutState[name].flex = flex;
store.set(REFLEX_LAYOUT, this.layoutState);
}
2018-04-06 14:51:52 +01:00
componentDidMount() {
const {
2018-05-24 19:45:38 +01:00
data: {
challengeNode: { 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: {
title: prevTitle,
fields: { tests: prevTests }
}
}
} = prevProps;
const {
data: {
challengeNode: {
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,
data: {
2018-05-24 19:45:38 +01:00
challengeNode: {
files,
fields: { tests },
challengeType,
removeComments,
helpCategory
2018-05-24 19:45:38 +01:00
}
2018-04-06 15:45:49 +01:00
},
2018-09-11 16:19:11 +03:00
pageContext: { challengeMeta }
2018-04-06 15:45:49 +01:00
} = this.props;
initConsole('');
createFiles(files);
initTests(tests);
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;
getBlockNameTitle() {
2018-04-06 14:51:52 +01:00
const {
fields: { blockName },
title
} = this.getChallenge();
return `${blockName}: ${title}`;
}
getVideoUrl = () => this.getChallenge().videoUrl;
hasPreview() {
const { challengeType } = this.getChallenge();
return (
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern
);
}
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
const { block, description, instructions, superBlock, translationPending } =
this.getChallenge();
const { forumTopicId, title } = this.getChallenge();
return (
<SidePanel
block={block}
className='full-height'
description={description}
guideUrl={getGuideUrl({ forumTopicId, title })}
instructions={instructions}
instructionsPanelRef={this.instructionsPanelRef}
showToolPanel={showToolPanel}
superBlock={superBlock}
title={title}
translationPending={translationPending}
videoUrl={this.getVideoUrl()}
/>
);
}
renderEditor() {
const { files } = this.props;
const { description } = this.getChallenge();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
2019-01-23 12:24:05 +03:00
return (
2020-05-05 07:48:51 -05:00
files && (
2020-07-27 15:20:19 +02:00
<MultifileEditor
2020-05-05 07:48:51 -05:00
challengeFiles={files}
containerRef={this.containerRef}
description={description}
2020-07-27 15:20:19 +02:00
editorRef={this.editorRef}
hasEditableBoundries={this.hasEditableBoundries()}
2020-07-27 15:20:19 +02:00
resizeProps={this.resizeProps}
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}
/>
);
}
renderPreview() {
2018-04-06 14:51:52 +01:00
return (
2019-01-23 12:24:05 +03:00
<Preview className='full-height' disableIframe={this.state.resizing} />
);
}
hasEditableBoundries() {
const { files } = this.props;
return Object.values(files).some(
file =>
file?.editableRegionBoundaries &&
file.editableRegionBoundaries.length === 2
);
}
render() {
const {
block,
fields: { blockName },
forumTopicId,
superBlock,
title
} = this.getChallenge();
const {
executeChallenge,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
files,
t
} = this.props;
return (
<Hotkeys
editorRef={this.editorRef}
executeChallenge={executeChallenge}
innerRef={this.containerRef}
instructionsPanelRef={this.instructionsPanelRef}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet
title={`${t(
'learn.learn'
)} ${this.getBlockNameTitle()} | freeCodeCamp.org`}
2019-05-14 17:37:13 +03:00
/>
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor()}
guideUrl={getGuideUrl({ forumTopicId, title })}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: false
})}
preview={this.renderPreview()}
testOutput={this.renderTestOutput()}
videoUrl={this.getVideoUrl()}
/>
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
challengeFiles={files}
editor={this.renderEditor()}
hasEditableBoundries={this.hasEditableBoundries()}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
layoutState={this.layoutState}
preview={this.renderPreview()}
resizeProps={this.resizeProps}
testOutput={this.renderTestOutput()}
/>
</Media>
<CompletionModal
block={block}
blockName={blockName}
superBlock={superBlock}
/>
<HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
</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
// 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.
2018-04-06 14:51:52 +01:00
export const query = graphql`
query ClassicChallenge($slug: String!) {
challengeNode(fields: { slug: { eq: $slug } }) {
block
2018-04-06 14:51:52 +01:00
title
description
instructions
removeComments
2018-04-06 14:51:52 +01:00
challengeType
helpCategory
videoUrl
superBlock
translationPending
forumTopicId
2018-04-06 14:51:52 +01:00
fields {
blockName
slug
2018-04-06 14:51:52 +01:00
tests {
text
testString
}
}
2018-04-11 14:43:23 +01:00
required {
link
src
}
2018-04-06 14:51:52 +01:00
files {
2020-05-05 07:48:51 -05:00
indexcss {
key
ext
name
contents
head
tail
editableRegionBoundaries
2020-05-05 07:48:51 -05:00
}
2018-04-06 14:51:52 +01:00
indexhtml {
key
ext
name
contents
head
tail
editableRegionBoundaries
2018-04-06 14:51:52 +01:00
}
indexjs {
key
ext
name
contents
head
tail
editableRegionBoundaries
2018-04-06 14:51:52 +01:00
}
2018-04-11 14:43:23 +01:00
indexjsx {
key
ext
name
contents
head
tail
editableRegionBoundaries
2018-04-11 14:43:23 +01:00
}
2018-04-06 14:51:52 +01:00
}
}
}
`;