From 2adaf4c87a07b5e505567f4fc98e7cdb45eac54d Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Wed, 22 Mar 2023 20:54:10 +0300 Subject: [PATCH] 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 Co-authored-by: Muhammed Mustafa --- .../templates/Challenges/classic/editor.tsx | 12 +- .../Challenges/classic/multifile-editor.tsx | 6 +- .../src/templates/Challenges/classic/show.tsx | 540 ++++++++---------- .../Challenges/components/hotkeys.tsx | 7 +- 4 files changed, 239 insertions(+), 326 deletions(-) diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index 994d2e12d4b..eb9d3f3ca16 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -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; + containerRef: MutableRefObject; contents: string; description: string; dimensions: Dimensions; - editorRef: MutableRefObject; + editorRef: MutableRefObject; executeChallenge: (options?: { showCompletionModal: boolean }) => void; ext: Ext; fileKey: FileKey; diff --git a/client/src/templates/Challenges/classic/multifile-editor.tsx b/client/src/templates/Challenges/classic/multifile-editor.tsx index c2d98d6705a..b02e4957dbd 100644 --- a/client/src/templates/Challenges/classic/multifile-editor.tsx +++ b/client/src/templates/Challenges/classic/multifile-editor.tsx @@ -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; + containerRef: MutableRefObject; contents?: string; description: string; dimensions?: Dimensions; - editorRef: MutableRefObject; + editorRef: MutableRefObject; ext?: Ext; fileKey?: string; initialEditorContent?: string; diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 1716475240b..713a3b24699 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -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 { - static displayName: string; - containerRef: React.RefObject; - editorRef: React.RefObject; - instructionsPanelRef: React.RefObject; - 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 { ); 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(); + const editorRef = useRef(); + const instructionsPanelRef = useRef(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 { 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 { }); 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 ( { } challengeTitle={ {title} } 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 && ( - } + 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={output} /> ); - } + }; - renderNotes(notes?: string) { + const renderNotes = (notes?: string) => { return ; - } + }; - renderPreview() { + const renderPreview = () => { return ( ); - } + }; - 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 ( - } - executeChallenge={executeChallenge} - innerRef={this.containerRef} - instructionsPanelRef={this.instructionsPanelRef} - nextChallengePath={nextChallengePath} - prevChallengePath={prevChallengePath} - usesMultifileEditor={usesMultifileEditor} - > - - - - - - - - - + + + + - - - - + + - - - - ); - } + + + + + + + + + + ); } ShowClassic.displayName = 'ShowClassic'; -export default connect( - mapStateToProps, - mapDispatchToProps -)(withTranslation()(ShowClassic)); +export default connect(mapStateToProps, mapDispatchToProps)(ShowClassic); export const query = graphql` query ClassicChallenge($slug: String!) { diff --git a/client/src/templates/Challenges/components/hotkeys.tsx b/client/src/templates/Challenges/components/hotkeys.tsx index a9d50cbcc73..db387eb1be4 100644 --- a/client/src/templates/Challenges/components/hotkeys.tsx +++ b/client/src/templates/Challenges/components/hotkeys.tsx @@ -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; + editorRef: MutableRefObject; executeChallenge?: (options?: { showCompletionModal: boolean }) => void; submitChallenge: () => void; - innerRef: React.Ref; + innerRef: MutableRefObject; instructionsPanelRef?: React.RefObject; nextChallengePath: string; prevChallengePath: string;