feat (client): turn classic show into a functional component (#49491)

* feat: turn classic show into a functional component

* fix: pass redux challengeFiles to components

* feat: add hooks

---------

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Muhammed Mustafa <muhammed@freecodecamp.org>
This commit is contained in:
Ahmad Abdolsaheb
2023-03-22 20:54:10 +03:00
committed by GitHub
parent 5cc3f6560f
commit 2adaf4c87a
4 changed files with 239 additions and 326 deletions

View File

@@ -9,13 +9,7 @@ import type {
} from 'monaco-editor/esm/vs/editor/editor.api';
import { OS } from 'monaco-editor/esm/vs/base/common/platform.js';
import Prism from 'prismjs';
import React, {
useEffect,
Suspense,
RefObject,
MutableRefObject,
useRef
} from 'react';
import React, { useEffect, Suspense, MutableRefObject, useRef } from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
@@ -79,11 +73,11 @@ interface EditorProps {
canFocus: boolean;
challengeFiles: ChallengeFiles;
challengeType: number;
containerRef: RefObject<HTMLElement>;
containerRef: MutableRefObject<HTMLElement | undefined>;
contents: string;
description: string;
dimensions: Dimensions;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor>;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor | undefined>;
executeChallenge: (options?: { showCompletionModal: boolean }) => void;
ext: Ext;
fileKey: FileKey;

View File

@@ -1,4 +1,4 @@
import React, { MutableRefObject, RefObject, useRef } from 'react';
import React, { MutableRefObject, useRef } from 'react';
import { connect } from 'react-redux';
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
import { createSelector } from 'reselect';
@@ -31,11 +31,11 @@ type VisibleEditors = {
interface MultifileEditorProps {
canFocus?: boolean;
challengeFiles: ChallengeFile[];
containerRef: RefObject<HTMLElement>;
containerRef: MutableRefObject<HTMLElement | undefined>;
contents?: string;
description: string;
dimensions?: Dimensions;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor>;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor | undefined>;
ext?: Ext;
fileKey?: string;
initialEditorContent?: string;

View File

@@ -1,7 +1,7 @@
import { graphql } from 'gatsby';
import React, { Component, MutableRefObject } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import Helmet from 'react-helmet';
import { TFunction, withTranslation } from 'react-i18next';
import { TFunction, useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { HandlerProps } from 'react-reflex';
import Media from 'react-responsive';
@@ -51,7 +51,6 @@ import {
} from '../redux/actions';
import {
challengeFilesSelector,
challengeTestsSelector,
consoleOutputSelector,
isChallengeCompletedSelector
} from '../redux/selectors';
@@ -67,7 +66,6 @@ import '../components/test-frame.css';
// Redux Setup
const mapStateToProps = createStructuredSelector({
challengeFiles: challengeFilesSelector,
tests: challengeTestsSelector,
output: consoleOutputSelector,
isChallengeCompleted: isChallengeCompletedSelector,
savedChallenges: savedChallengesSelector
@@ -110,8 +108,6 @@ interface ShowClassicProps {
showProjectPreview: boolean;
};
};
t: TFunction;
tests: Test[];
updateChallengeMeta: (arg0: ChallengeMeta) => void;
openModal: (modal: string) => void;
setEditorFocusability: (canFocus: boolean) => void;
@@ -120,12 +116,6 @@ interface ShowClassicProps {
savedChallenges: CompletedChallenge[];
}
interface ShowClassicState {
layout: ReflexLayout;
resizing: boolean;
usingKeyboardInTablist: boolean;
}
interface ReflexLayout {
codePane: { flex: number };
editorPane: { flex: number };
@@ -160,41 +150,80 @@ const handleContentWidgetEvents = (e: MouseEvent | TouchEvent): void => {
};
// Component
class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
static displayName: string;
containerRef: React.RefObject<HTMLElement>;
editorRef: React.RefObject<editor.IStandaloneCodeEditor | HTMLElement>;
instructionsPanelRef: React.RefObject<HTMLDivElement>;
resizeProps: ResizeProps;
function ShowClassic({
challengeFiles: reduxChallengeFiles,
data: {
challengeNode: {
challenge: {
challengeFiles,
block,
title,
description,
instructions,
fields: { tests, blockName },
challengeType,
removeComments,
hasEditableBoundaries,
superBlock,
helpCategory,
forumTopicId,
certification,
usesMultifileEditor,
notes,
videoUrl,
translationPending
}
}
},
pageContext: {
challengeMeta,
challengeMeta: { isFirstStep, nextChallengePath, prevChallengePath },
projectPreview: { challengeData, showProjectPreview }
},
createFiles,
cancelTests,
challengeMounted,
initConsole,
initTests,
updateChallengeMeta,
openModal,
setIsAdvancing,
savedChallenges,
isChallengeCompleted,
output,
executeChallenge
}: ShowClassicProps) {
const { t } = useTranslation();
const onStopResize = (event: HandlerProps) => {
const { name, flex } = event.component.props;
constructor(props: ShowClassicProps) {
super(props);
// Only interested in tracking layout updates for ReflexElement's
if (!name) {
setResizing(false);
return;
}
this.resizeProps = {
onStopResize: this.onStopResize.bind(this),
onResize: this.onResize.bind(this)
};
// Forcing a state update with the value of each panel since on stop resize
// is executed per each panel.
if (typeof layout === 'object') {
setLayout({
...layout,
[name]: { flex }
});
}
setResizing(false);
// layout: Holds the information of the panes sizes for desktop view
this.state = {
layout: this.getLayoutState(),
resizing: false,
usingKeyboardInTablist: false
};
store.set(REFLEX_LAYOUT, layout);
};
const onResize = () => {
setResizing(true);
};
const resizeProps: ResizeProps = {
onResize,
onStopResize
};
this.containerRef = React.createRef();
this.editorRef = React.createRef();
this.instructionsPanelRef = React.createRef();
this.updateUsingKeyboardInTablist =
this.updateUsingKeyboardInTablist.bind(this);
}
updateUsingKeyboardInTablist(usingKeyboardInTablist: boolean): void {
this.setState({ usingKeyboardInTablist });
}
getLayoutState(): ReflexLayout {
const getLayoutState = (): ReflexLayout => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const reflexLayout: ReflexLayout = store.get(REFLEX_LAYOUT);
@@ -210,48 +239,25 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
);
return isValidLayout ? reflexLayout : BASE_LAYOUT;
}
};
onResize() {
this.setState(state => ({ ...state, resizing: true }));
}
// layout: Holds the information of the panes sizes for desktop view
const [layout, setLayout] = useState(getLayoutState());
const [resizing, setResizing] = useState(false);
const [usingKeyboardInTablist, setUsingKeyboardInTablist] = useState(false);
onStopResize(event: HandlerProps) {
const { name, flex } = event.component.props;
const containerRef = useRef<HTMLElement>();
const editorRef = useRef<editor.IStandaloneCodeEditor>();
const instructionsPanelRef = useRef<HTMLDivElement>(null);
// Only interested in tracking layout updates for ReflexElement's
if (!name) {
this.setState(state => ({ ...state, resizing: false }));
return;
}
const updateUsingKeyboardInTablist = (
usingKeyboardInTablist: boolean
): void => {
setUsingKeyboardInTablist(usingKeyboardInTablist);
};
// 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);
}
componentDidMount() {
const {
data: {
challengeNode: {
challenge: { title }
}
}
} = this.props;
this.initializeComponent(title);
useEffect(() => {
initializeComponent(title);
// Bug fix for the monaco content widget and touch devices/right mouse
// click. (Issue #46166)
document.addEventListener('mousedown', handleContentWidgetEvents, true);
@@ -259,60 +265,41 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
document.addEventListener('touchstart', handleContentWidgetEvents, true);
document.addEventListener('touchmove', handleContentWidgetEvents, true);
document.addEventListener('touchend', handleContentWidgetEvents, true);
}
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);
}
}
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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
initializeComponent(title: string) {
const {
challengeMounted,
createFiles,
initConsole,
initTests,
updateChallengeMeta,
openModal,
setIsAdvancing,
savedChallenges,
data: {
challengeNode: {
challenge: {
challengeFiles,
fields: { tests },
challengeType,
removeComments,
helpCategory
}
}
},
pageContext: {
challengeMeta,
projectPreview: { showProjectPreview }
}
} = this.props;
useEffect(() => {
initializeComponent(title);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tests, title]);
const initializeComponent = (title: string): void => {
initConsole('');
const savedChallenge = savedChallenges?.find(challenge => {
@@ -332,51 +319,25 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
});
challengeMounted(challengeMeta.id);
setIsAdvancing(false);
}
};
componentWillUnmount() {
const { createFiles, cancelTests } = this.props;
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);
}
getChallenge = () => this.props.data.challengeNode.challenge;
getBlockNameTitle(t: TFunction) {
const { block, superBlock, title } = this.getChallenge();
const getBlockNameTitle = (t: TFunction): string => {
return `${t(`intro:${superBlock}.blocks.${block}.title`)}: ${title}`;
}
};
getVideoUrl = () => this.getChallenge().videoUrl;
hasPreview() {
const { challengeType } = this.getChallenge();
const hasPreview = () => {
return (
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multifileCertProject
);
}
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
const {
block,
description,
forumTopicId,
instructions,
title,
translationPending
} = this.getChallenge();
};
const renderInstructionsPanel = ({
showToolPanel
}: {
showToolPanel: boolean;
}) => {
return (
<SidePanel
block={block}
@@ -389,60 +350,45 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
}
challengeTitle={
<ChallengeTitle
isCompleted={this.props.isChallengeCompleted}
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
}
guideUrl={getGuideUrl({ forumTopicId, title })}
instructionsPanelRef={this.instructionsPanelRef}
instructionsPanelRef={instructionsPanelRef}
showToolPanel={showToolPanel}
videoUrl={this.getVideoUrl()}
videoUrl={videoUrl}
/>
);
}
};
renderEditor({ isMobileLayout, isUsingKeyboardInTablist }: RenderEditorArgs) {
const {
pageContext: {
projectPreview: { showProjectPreview }
},
challengeFiles,
data: {
challengeNode: {
challenge: {
fields: { tests },
usesMultifileEditor
}
}
}
} = this.props;
const { description, title } = this.getChallenge();
const renderEditor = ({
isMobileLayout,
isUsingKeyboardInTablist
}: RenderEditorArgs) => {
return (
challengeFiles && (
reduxChallengeFiles && (
<MultifileEditor
challengeFiles={challengeFiles}
containerRef={this.containerRef}
challengeFiles={reduxChallengeFiles}
containerRef={containerRef}
description={description}
// Try to remove unknown
editorRef={
this.editorRef as MutableRefObject<editor.IStandaloneCodeEditor>
}
editorRef={editorRef}
initialTests={tests}
isMobileLayout={isMobileLayout}
isUsingKeyboardInTablist={isUsingKeyboardInTablist}
resizeProps={this.resizeProps}
resizeProps={resizeProps}
title={title}
usesMultifileEditor={usesMultifileEditor}
showProjectPreview={showProjectPreview}
/>
)
);
}
};
renderTestOutput() {
const { output, t } = this.props;
const renderTestOutput = () => {
return (
<Output
defaultOutput={`
@@ -453,134 +399,106 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
output={output}
/>
);
}
};
renderNotes(notes?: string) {
const renderNotes = (notes?: string) => {
return <Notes notes={notes} />;
}
};
renderPreview() {
const renderPreview = () => {
return (
<Preview
className='full-height'
disableIframe={this.state.resizing}
previewMounted={this.props.previewMounted}
disableIframe={resizing}
previewMounted={previewMounted}
/>
);
}
};
render() {
const {
block,
challengeType,
fields: { blockName },
forumTopicId,
hasEditableBoundaries,
superBlock,
certification,
title,
usesMultifileEditor,
notes
} = this.getChallenge();
const {
executeChallenge,
pageContext: {
challengeMeta: { isFirstStep, nextChallengePath, prevChallengePath },
projectPreview: { challengeData, showProjectPreview }
},
challengeFiles,
t
} = this.props;
const blockNameTitle = getBlockNameTitle(t);
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
const blockNameTitle = this.getBlockNameTitle(t);
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
return (
<Hotkeys
challengeType={challengeType}
editorRef={this.editorRef as React.RefObject<HTMLElement>}
executeChallenge={executeChallenge}
innerRef={this.containerRef}
instructionsPanelRef={this.instructionsPanelRef}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
usesMultifileEditor={usesMultifileEditor}
>
<LearnLayout>
<Helmet title={windowTitle} />
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor({
isMobileLayout: true,
isUsingKeyboardInTablist: this.state.usingKeyboardInTablist
})}
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()}
// eslint-disable-next-line @typescript-eslint/unbound-method
updateUsingKeyboardInTablist={this.updateUsingKeyboardInTablist}
usesMultifileEditor={usesMultifileEditor}
videoUrl={this.getVideoUrl()}
/>
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
challengeFiles={challengeFiles}
challengeType={challengeType}
editor={this.renderEditor({
isMobileLayout: false,
isUsingKeyboardInTablist: this.state.usingKeyboardInTablist
})}
hasEditableBoundaries={hasEditableBoundaries}
hasNotes={!!notes}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
isFirstStep={isFirstStep}
layoutState={this.state.layout}
notes={this.renderNotes(notes)}
preview={this.renderPreview()}
resizeProps={this.resizeProps}
testOutput={this.renderTestOutput()}
windowTitle={windowTitle}
/>
</Media>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
return (
<Hotkeys
challengeType={challengeType}
executeChallenge={executeChallenge}
innerRef={containerRef}
instructionsPanelRef={instructionsPanelRef}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
usesMultifileEditor={usesMultifileEditor}
{...(editorRef && { editorRef: editorRef })}
>
<LearnLayout>
<Helmet title={windowTitle} />
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={renderEditor({
isMobileLayout: true,
isUsingKeyboardInTablist: usingKeyboardInTablist
})}
guideUrl={getGuideUrl({ forumTopicId, title })}
hasEditableBoundaries={hasEditableBoundaries}
hasNotes={!!notes}
hasPreview={hasPreview()}
instructions={renderInstructionsPanel({
showToolPanel: false
})}
notes={renderNotes(notes)}
preview={renderPreview()}
testOutput={renderTestOutput()}
updateUsingKeyboardInTablist={updateUsingKeyboardInTablist}
usesMultifileEditor={usesMultifileEditor}
videoUrl={videoUrl}
/>
<HelpModal challengeTitle={title} challengeBlock={blockName} />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
<ProjectPreviewModal
challengeData={challengeData}
closeText={t('buttons.start-coding')}
previewTitle={t('learn.project-preview-title')}
showProjectPreview={showProjectPreview}
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
challengeFiles={reduxChallengeFiles}
challengeType={challengeType}
editor={renderEditor({
isMobileLayout: false,
isUsingKeyboardInTablist: usingKeyboardInTablist
})}
hasEditableBoundaries={hasEditableBoundaries}
hasNotes={!!notes}
hasPreview={hasPreview()}
instructions={renderInstructionsPanel({
showToolPanel: true
})}
isFirstStep={isFirstStep}
layoutState={layout}
notes={renderNotes(notes)}
preview={renderPreview()}
resizeProps={resizeProps}
testOutput={renderTestOutput()}
windowTitle={windowTitle}
/>
<ShortcutsModal />
</LearnLayout>
</Hotkeys>
);
}
</Media>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<HelpModal challengeTitle={title} challengeBlock={blockName} />
<VideoModal videoUrl={videoUrl} />
<ResetModal />
<ProjectPreviewModal
challengeData={challengeData}
closeText={t('buttons.start-coding')}
previewTitle={t('learn.project-preview-title')}
showProjectPreview={showProjectPreview}
/>
<ShortcutsModal />
</LearnLayout>
</Hotkeys>
);
}
ShowClassic.displayName = 'ShowClassic';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(ShowClassic));
export default connect(mapStateToProps, mapDispatchToProps)(ShowClassic);
export const query = graphql`
query ClassicChallenge($slug: String!) {

View File

@@ -1,8 +1,9 @@
import { navigate } from 'gatsby';
import React from 'react';
import React, { MutableRefObject } from 'react';
import { HotKeys, GlobalHotKeys } from 'react-hotkeys';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { editor } from 'monaco-editor';
import { ChallengeFiles, Test, User } from '../../../redux/prop-types';
import { isChallenge } from '../../../utils/path-parsers';
@@ -61,10 +62,10 @@ interface HotkeysProps {
challengeFiles: ChallengeFiles;
challengeType?: number;
children: React.ReactElement;
editorRef?: React.RefObject<HTMLElement>;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor | undefined>;
executeChallenge?: (options?: { showCompletionModal: boolean }) => void;
submitChallenge: () => void;
innerRef: React.Ref<HTMLElement>;
innerRef: MutableRefObject<HTMLElement | undefined>;
instructionsPanelRef?: React.RefObject<HTMLElement>;
nextChallengePath: string;
prevChallengePath: string;