feat(client): ts-migrate challenge templates/show components (#42553)
This commit is contained in:
@ -160,7 +160,9 @@ export type ChallengeNodeType = {
|
||||
fields: {
|
||||
slug: string;
|
||||
blockName: string;
|
||||
tests: TestType[];
|
||||
};
|
||||
files: ChallengeFilesType;
|
||||
forumTopicId: number;
|
||||
guideUrl: string;
|
||||
head: string[];
|
||||
@ -171,6 +173,11 @@ export type ChallengeNodeType = {
|
||||
isLocked: boolean;
|
||||
isPrivate: boolean;
|
||||
order: number;
|
||||
question: {
|
||||
text: string;
|
||||
answers: string[];
|
||||
solution: number;
|
||||
};
|
||||
required: [
|
||||
{
|
||||
link: string;
|
||||
@ -184,6 +191,8 @@ export type ChallengeNodeType = {
|
||||
time: string;
|
||||
title: string;
|
||||
translationPending: boolean;
|
||||
url: string;
|
||||
videoId: string;
|
||||
videoUrl: string;
|
||||
};
|
||||
|
||||
@ -204,7 +213,7 @@ export type AllMarkdownRemarkType = {
|
||||
};
|
||||
|
||||
export type ResizePropsType = {
|
||||
onStopResize: () => void;
|
||||
onStopResize: (arg0: React.ChangeEvent) => void;
|
||||
onResize: () => void;
|
||||
};
|
||||
|
||||
@ -289,6 +298,28 @@ export type ChallengeFileType = {
|
||||
export type ExtTypes = 'js' | 'html' | 'css' | 'jsx';
|
||||
export type FileKeyTypes = 'indexjs' | 'indexhtml' | 'indexcss';
|
||||
|
||||
export type ChallengeFilesType =
|
||||
| {
|
||||
indexcss: ChallengeFileType;
|
||||
indexhtml: ChallengeFileType;
|
||||
indexjs: ChallengeFileType;
|
||||
indexjsx: ChallengeFileType;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
export type ChallengeMetaType = {
|
||||
block: string;
|
||||
id: string;
|
||||
introPath: string;
|
||||
nextChallengePath: string;
|
||||
prevChallengePath: string;
|
||||
removeComments: boolean;
|
||||
superBlock: string;
|
||||
title?: string;
|
||||
challengeType?: number;
|
||||
helpCategory: string;
|
||||
};
|
||||
|
||||
export type PortfolioType = {
|
||||
id: string;
|
||||
title?: string;
|
||||
@ -297,6 +328,7 @@ export type PortfolioType = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
// This looks redundant - same as ChallengeNodeType above?
|
||||
export type ChallengeNode = {
|
||||
block: string;
|
||||
challengeOrder: number;
|
||||
|
@ -1,6 +1,9 @@
|
||||
/* 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 PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import { connect } from 'react-redux';
|
||||
import Helmet from 'react-helmet';
|
||||
@ -8,6 +11,7 @@ import { graphql } from 'gatsby';
|
||||
import Media from 'react-responsive';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
|
||||
// Local Utilities
|
||||
import LearnLayout from '../../../components/layouts/Learn';
|
||||
import MultifileEditor from './MultifileEditor';
|
||||
import Preview from '../components/Preview';
|
||||
@ -20,12 +24,17 @@ 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 { ChallengeNode } from '../../../redux/prop-types';
|
||||
import {
|
||||
ChallengeNodeType,
|
||||
ChallengeFilesType,
|
||||
ChallengeMetaType,
|
||||
TestType,
|
||||
ResizePropsType
|
||||
} from '../../../redux/prop-types';
|
||||
import {
|
||||
createFiles,
|
||||
challengeFilesSelector,
|
||||
@ -39,16 +48,18 @@ import {
|
||||
cancelTests
|
||||
} from '../redux';
|
||||
|
||||
// Styles
|
||||
import './classic.css';
|
||||
import '../components/test-frame.css';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
files: challengeFilesSelector,
|
||||
tests: challengeTestsSelector,
|
||||
output: consoleOutputSelector
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
createFiles,
|
||||
@ -62,60 +73,58 @@ const mapDispatchToProps = dispatch =>
|
||||
dispatch
|
||||
);
|
||||
|
||||
const propTypes = {
|
||||
cancelTests: PropTypes.func.isRequired,
|
||||
challengeMounted: PropTypes.func.isRequired,
|
||||
createFiles: PropTypes.func.isRequired,
|
||||
data: PropTypes.shape({
|
||||
challengeNode: ChallengeNode
|
||||
}),
|
||||
executeChallenge: PropTypes.func.isRequired,
|
||||
files: PropTypes.shape({
|
||||
key: PropTypes.string
|
||||
}),
|
||||
initConsole: PropTypes.func.isRequired,
|
||||
initTests: PropTypes.func.isRequired,
|
||||
output: PropTypes.arrayOf(PropTypes.string),
|
||||
pageContext: PropTypes.shape({
|
||||
challengeMeta: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
nextChallengePath: PropTypes.string,
|
||||
prevChallengePath: PropTypes.string
|
||||
})
|
||||
}),
|
||||
t: PropTypes.func.isRequired,
|
||||
tests: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
testString: PropTypes.string
|
||||
})
|
||||
),
|
||||
updateChallengeMeta: PropTypes.func.isRequired
|
||||
};
|
||||
// Types
|
||||
interface ShowClassicProps {
|
||||
cancelTests: () => void;
|
||||
challengeMounted: (arg0: string) => void;
|
||||
createFiles: (arg0: ChallengeFilesType) => void;
|
||||
data: { challengeNode: ChallengeNodeType };
|
||||
executeChallenge: () => void;
|
||||
files: ChallengeFilesType;
|
||||
initConsole: (arg0: string) => void;
|
||||
initTests: (tests: TestType[]) => void;
|
||||
output: string[];
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMetaType;
|
||||
};
|
||||
t: (arg0: string) => string;
|
||||
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 };
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
codePane: { flex: 1 },
|
||||
editorPane: { flex: 1 },
|
||||
instructionPane: { flex: 1 },
|
||||
previewPane: { flex: 0.7 },
|
||||
testsPane: { flex: 0.25 }
|
||||
};
|
||||
|
||||
class ShowClassic extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
// Component
|
||||
class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
|
||||
static displayName: string;
|
||||
containerRef: React.RefObject<unknown>;
|
||||
editorRef: React.RefObject<unknown>;
|
||||
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),
|
||||
@ -132,8 +141,8 @@ class ShowClassic extends Component {
|
||||
this.layoutState = this.getLayoutState();
|
||||
}
|
||||
|
||||
getLayoutState() {
|
||||
const reflexLayout = store.get(REFLEX_LAYOUT);
|
||||
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;
|
||||
@ -153,7 +162,8 @@ class ShowClassic extends Component {
|
||||
this.setState({ resizing: true });
|
||||
}
|
||||
|
||||
onStopResize(event) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onStopResize(event: any) {
|
||||
const { name, flex } = event.component.props;
|
||||
|
||||
this.setState({ resizing: false });
|
||||
@ -177,7 +187,7 @@ class ShowClassic extends Component {
|
||||
this.initializeComponent(title);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: ShowClassicProps) {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: {
|
||||
@ -199,7 +209,7 @@ class ShowClassic extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
initializeComponent(title) {
|
||||
initializeComponent(title: string) {
|
||||
const {
|
||||
challengeMounted,
|
||||
createFiles,
|
||||
@ -256,7 +266,7 @@ class ShowClassic extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderInstructionsPanel({ showToolPanel }) {
|
||||
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
|
||||
const { block, description, instructions, superBlock, translationPending } =
|
||||
this.getChallenge();
|
||||
|
||||
@ -397,7 +407,6 @@ class ShowClassic extends Component {
|
||||
}
|
||||
|
||||
ShowClassic.displayName = 'ShowClassic';
|
||||
ShowClassic.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
@ -2,20 +2,23 @@
|
||||
|
||||
// Package Utilities
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import Helmet from 'react-helmet';
|
||||
import { graphql } from 'gatsby';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
// Local Utilities
|
||||
import LearnLayout from '../../../components/layouts/Learn';
|
||||
import { ChallengeNode } from '../../../redux/prop-types';
|
||||
import {
|
||||
ChallengeNodeType,
|
||||
ChallengeMetaType
|
||||
} from '../../../redux/prop-types';
|
||||
import { updateChallengeMeta, challengeMounted } from '../redux';
|
||||
|
||||
// Redux
|
||||
const mapStateToProps = () => ({});
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
updateChallengeMeta,
|
||||
@ -24,25 +27,19 @@ const mapDispatchToProps = dispatch =>
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Proptypes
|
||||
const propTypes = {
|
||||
data: PropTypes.shape({
|
||||
challengeNode: ChallengeNode
|
||||
}),
|
||||
pageContext: PropTypes.shape({
|
||||
challengeMeta: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
introPath: PropTypes.string,
|
||||
nextChallengePath: PropTypes.string,
|
||||
prevChallengePath: PropTypes.string
|
||||
})
|
||||
}),
|
||||
updateChallengeMeta: PropTypes.func.isRequired
|
||||
};
|
||||
// Types
|
||||
interface ShowCodeAllyProps {
|
||||
data: { challengeNode: ChallengeNodeType };
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMetaType;
|
||||
};
|
||||
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
|
||||
}
|
||||
|
||||
// Component
|
||||
class ShowCodeAlly extends Component {
|
||||
componentDidMount() {
|
||||
class ShowCodeAlly extends Component<ShowCodeAllyProps> {
|
||||
static displayName: string;
|
||||
componentDidMount(): void {
|
||||
const {
|
||||
updateChallengeMeta,
|
||||
data: {
|
||||
@ -65,6 +62,7 @@ class ShowCodeAlly extends Component {
|
||||
<Helmet title={`${blockName}: ${title} | freeCodeCamp.org`} />
|
||||
<iframe
|
||||
sandbox='allow-modals allow-forms allow-popups allow-scripts allow-same-origin'
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
src={`http://codeally.io/embed/?repoUrl=${url}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -80,7 +78,6 @@ class ShowCodeAlly extends Component {
|
||||
}
|
||||
|
||||
ShowCodeAlly.displayName = 'ShowCodeAlly';
|
||||
ShowCodeAlly.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ShowCodeAlly);
|
||||
|
@ -4,9 +4,9 @@ import PrismFormatted from './PrismFormatted';
|
||||
import './challenge-description.css';
|
||||
|
||||
type Challenge = {
|
||||
block: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
block?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
};
|
||||
|
||||
function ChallengeDescription(challenge: Challenge): JSX.Element {
|
||||
|
@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { HotKeys, GlobalHotKeys } from 'react-hotkeys';
|
||||
import { navigate } from 'gatsby';
|
||||
import { connect } from 'react-redux';
|
||||
@ -10,7 +11,7 @@ import './hotkeys.css';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
canFocusEditorSelector,
|
||||
canFocusEditor => ({
|
||||
(canFocusEditor: boolean) => ({
|
||||
canFocusEditor
|
||||
})
|
||||
);
|
||||
@ -25,16 +26,17 @@ const keyMap = {
|
||||
NAVIGATE_NEXT: ['n']
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
canFocusEditor: PropTypes.bool,
|
||||
children: PropTypes.any,
|
||||
editorRef: PropTypes.object,
|
||||
executeChallenge: PropTypes.func,
|
||||
innerRef: PropTypes.any,
|
||||
nextChallengePath: PropTypes.string,
|
||||
prevChallengePath: PropTypes.string,
|
||||
setEditorFocusability: PropTypes.func.isRequired
|
||||
};
|
||||
interface HotkeysProps {
|
||||
canFocusEditor: boolean;
|
||||
children: React.ReactElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editorRef?: React.Ref<HTMLElement> | any;
|
||||
executeChallenge?: () => void;
|
||||
innerRef: React.Ref<HTMLElement> | unknown;
|
||||
nextChallengePath: string;
|
||||
prevChallengePath: string;
|
||||
setEditorFocusability: (arg0: boolean) => void;
|
||||
}
|
||||
|
||||
function Hotkeys({
|
||||
canFocusEditor,
|
||||
@ -45,9 +47,9 @@ function Hotkeys({
|
||||
nextChallengePath,
|
||||
prevChallengePath,
|
||||
setEditorFocusability
|
||||
}) {
|
||||
}: HotkeysProps): JSX.Element {
|
||||
const handlers = {
|
||||
EXECUTE_CHALLENGE: e => {
|
||||
EXECUTE_CHALLENGE: (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
// the 'enter' part of 'ctrl+enter' stops HotKeys from listening, so it
|
||||
// needs to be prevented.
|
||||
// TODO: 'enter' on its own also disables HotKeys, but default behaviour
|
||||
@ -55,7 +57,7 @@ function Hotkeys({
|
||||
e.preventDefault();
|
||||
if (executeChallenge) executeChallenge();
|
||||
},
|
||||
FOCUS_EDITOR: e => {
|
||||
FOCUS_EDITOR: (e: React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (editorRef && editorRef.current) {
|
||||
editorRef.current.getWrappedInstance().focusOnEditor();
|
||||
@ -63,10 +65,10 @@ function Hotkeys({
|
||||
},
|
||||
NAVIGATION_MODE: () => setEditorFocusability(false),
|
||||
NAVIGATE_PREV: () => {
|
||||
if (!canFocusEditor) navigate(prevChallengePath);
|
||||
if (!canFocusEditor) void navigate(prevChallengePath);
|
||||
},
|
||||
NAVIGATE_NEXT: () => {
|
||||
if (!canFocusEditor) navigate(nextChallengePath);
|
||||
if (!canFocusEditor) void navigate(nextChallengePath);
|
||||
}
|
||||
};
|
||||
// GlobalHotKeys is always mounted and tracks all keypresses. Without it,
|
||||
@ -75,19 +77,22 @@ function Hotkeys({
|
||||
// allowChanges is necessary if the handlers depend on props (in this case
|
||||
// canFocusEditor)
|
||||
return (
|
||||
<HotKeys
|
||||
allowChanges={true}
|
||||
handlers={handlers}
|
||||
innerRef={innerRef}
|
||||
keyMap={keyMap}
|
||||
>
|
||||
{children}
|
||||
<GlobalHotKeys />
|
||||
</HotKeys>
|
||||
<>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<HotKeys
|
||||
allowChanges={true}
|
||||
handlers={handlers}
|
||||
innerRef={innerRef}
|
||||
keyMap={keyMap}
|
||||
>
|
||||
{children}
|
||||
<GlobalHotKeys />
|
||||
</HotKeys>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Hotkeys.displayName = 'Hotkeys';
|
||||
Hotkeys.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Hotkeys);
|
@ -1,28 +1,35 @@
|
||||
// Package Utilities
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Button, Modal } from '@freecodecamp/react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Local Utilities
|
||||
import { isResetModalOpenSelector, closeModal, resetChallenge } from '../redux';
|
||||
import { executeGA } from '../../../redux';
|
||||
|
||||
// Styles
|
||||
import './reset-modal.css';
|
||||
|
||||
const propTypes = {
|
||||
close: PropTypes.func.isRequired,
|
||||
executeGA: PropTypes.func,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
reset: PropTypes.func.isRequired
|
||||
};
|
||||
// Types
|
||||
interface ResetModalProps {
|
||||
close: () => void;
|
||||
executeGA: () => void;
|
||||
isOpen: boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const mapStateToProps = createSelector(isResetModalOpenSelector, isOpen => ({
|
||||
isOpen
|
||||
}));
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
isResetModalOpenSelector,
|
||||
(isOpen: boolean) => ({
|
||||
isOpen
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
close: () => closeModal('reset'),
|
||||
@ -32,11 +39,12 @@ const mapDispatchToProps = dispatch =>
|
||||
dispatch
|
||||
);
|
||||
|
||||
function withActions(...fns) {
|
||||
function withActions(...fns: Array<() => void>) {
|
||||
return () => fns.forEach(fn => fn());
|
||||
}
|
||||
|
||||
function ResetModal({ reset, close, isOpen }) {
|
||||
// Component
|
||||
function ResetModal({ reset, close, isOpen }: ResetModalProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
if (isOpen) {
|
||||
executeGA({ type: 'modal', data: '/reset-modal' });
|
||||
@ -75,6 +83,5 @@ function ResetModal({ reset, close, isOpen }) {
|
||||
}
|
||||
|
||||
ResetModal.displayName = 'ResetModal';
|
||||
ResetModal.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ResetModal);
|
@ -1,5 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
// Package Utilities
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Grid, Col, Row } from '@freecodecamp/react-bootstrap';
|
||||
import { createSelector } from 'reselect';
|
||||
import { connect } from 'react-redux';
|
||||
@ -7,6 +9,7 @@ import { graphql } from 'gatsby';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
|
||||
// Local Utilities
|
||||
import {
|
||||
executeChallenge,
|
||||
challengeMounted,
|
||||
@ -19,7 +22,6 @@ import {
|
||||
updateSolutionFormValues
|
||||
} from '../../redux';
|
||||
import { getGuideUrl } from '../../utils';
|
||||
|
||||
import LearnLayout from '../../../../components/layouts/Learn';
|
||||
import ChallengeTitle from '../../components/Challenge-Title';
|
||||
import ChallengeDescription from '../../components/Challenge-Description';
|
||||
@ -30,42 +32,29 @@ import HelpModal from '../../components/HelpModal';
|
||||
import ProjectToolPanel from '../Tool-Panel';
|
||||
import SolutionForm from '../SolutionForm';
|
||||
import Spacer from '../../../../components/helpers/spacer';
|
||||
import { ChallengeNode } from '../../../../redux/prop-types';
|
||||
import {
|
||||
ChallengeNodeType,
|
||||
ChallengeMetaType,
|
||||
TestType
|
||||
} from '../../../../redux/prop-types';
|
||||
import { isSignedInSelector } from '../../../../redux';
|
||||
import Hotkeys from '../../components/Hotkeys';
|
||||
|
||||
// Styles
|
||||
import '../../components/test-frame.css';
|
||||
|
||||
const propTypes = {
|
||||
challengeMounted: PropTypes.func.isRequired,
|
||||
data: PropTypes.shape({
|
||||
challengeNode: ChallengeNode
|
||||
}),
|
||||
description: PropTypes.string,
|
||||
executeChallenge: PropTypes.func.isRequired,
|
||||
forumTopicId: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
initConsole: PropTypes.func.isRequired,
|
||||
initTests: PropTypes.func.isRequired,
|
||||
isChallengeCompleted: PropTypes.bool,
|
||||
isSignedIn: PropTypes.bool,
|
||||
output: PropTypes.arrayOf(PropTypes.string),
|
||||
pageContext: PropTypes.shape({
|
||||
challengeMeta: PropTypes.object
|
||||
}),
|
||||
t: PropTypes.func.isRequired,
|
||||
tests: PropTypes.array,
|
||||
title: PropTypes.string,
|
||||
updateChallengeMeta: PropTypes.func.isRequired,
|
||||
updateSolutionFormValues: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
consoleOutputSelector,
|
||||
challengeTestsSelector,
|
||||
isChallengeCompletedSelector,
|
||||
isSignedInSelector,
|
||||
(output, tests, isChallengeCompleted, isSignedIn) => ({
|
||||
(
|
||||
output: string[],
|
||||
tests: TestType[],
|
||||
isChallengeCompleted: boolean,
|
||||
isSignedIn: boolean
|
||||
) => ({
|
||||
tests,
|
||||
output,
|
||||
isChallengeCompleted,
|
||||
@ -82,8 +71,35 @@ const mapDispatchToActions = {
|
||||
updateSolutionFormValues
|
||||
};
|
||||
|
||||
class BackEnd extends Component {
|
||||
constructor(props) {
|
||||
// Types
|
||||
interface BackEndProps {
|
||||
challengeMounted: (arg0: string) => void;
|
||||
data: { challengeNode: ChallengeNodeType };
|
||||
description: string;
|
||||
executeChallenge: (arg0: boolean) => void;
|
||||
forumTopicId: number;
|
||||
id: string;
|
||||
initConsole: () => void;
|
||||
initTests: (tests: TestType[]) => void;
|
||||
isChallengeCompleted: boolean;
|
||||
isSignedIn: boolean;
|
||||
output: string[];
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMetaType;
|
||||
};
|
||||
t: (arg0: string) => string;
|
||||
tests: TestType[];
|
||||
title: string;
|
||||
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
|
||||
updateSolutionFormValues: () => void;
|
||||
}
|
||||
|
||||
// Component
|
||||
class BackEnd extends Component<BackEndProps> {
|
||||
static displayName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _container: any;
|
||||
constructor(props: BackEndProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.updateDimensions = this.updateDimensions.bind(this);
|
||||
@ -92,7 +108,7 @@ class BackEnd extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.initializeComponent();
|
||||
window.addEventListener('resize', this.updateDimensions);
|
||||
window.addEventListener('resize', () => this.updateDimensions());
|
||||
this._container.focus();
|
||||
}
|
||||
|
||||
@ -101,10 +117,10 @@ class BackEnd extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.updateDimensions);
|
||||
window.removeEventListener('resize', () => this.updateDimensions());
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: BackEndProps) {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: {
|
||||
@ -122,7 +138,7 @@ class BackEnd extends Component {
|
||||
}
|
||||
} = this.props;
|
||||
if (prevTitle !== currentTitle || prevTests !== currTests) {
|
||||
this.initializeComponent(currentTitle);
|
||||
this.initializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,7 +169,11 @@ class BackEnd extends Component {
|
||||
challengeMounted(challengeMeta.id);
|
||||
}
|
||||
|
||||
handleSubmit({ isShouldCompletionModalOpen }) {
|
||||
handleSubmit({
|
||||
isShouldCompletionModalOpen
|
||||
}: {
|
||||
isShouldCompletionModalOpen: boolean;
|
||||
}): void {
|
||||
this.props.executeChallenge(isShouldCompletionModalOpen);
|
||||
}
|
||||
|
||||
@ -186,7 +206,7 @@ class BackEnd extends Component {
|
||||
|
||||
return (
|
||||
<Hotkeys
|
||||
innerRef={c => (this._container = c)}
|
||||
innerRef={(c: HTMLElement | null) => (this._container = c)}
|
||||
nextChallengePath={nextChallengePath}
|
||||
prevChallengePath={prevChallengePath}
|
||||
>
|
||||
@ -197,6 +217,8 @@ class BackEnd extends Component {
|
||||
<Grid>
|
||||
<Row>
|
||||
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<ChallengeTitle
|
||||
block={block}
|
||||
@ -212,6 +234,7 @@ class BackEnd extends Component {
|
||||
/>
|
||||
<SolutionForm
|
||||
challengeType={challengeType}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
onSubmit={this.handleSubmit}
|
||||
updateSolutionForm={updateSolutionFormValues}
|
||||
/>
|
||||
@ -231,6 +254,8 @@ class BackEnd extends Component {
|
||||
output={output}
|
||||
/>
|
||||
<TestSuite tests={tests} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
</Col>
|
||||
<CompletionModal
|
||||
@ -248,7 +273,6 @@ class BackEnd extends Component {
|
||||
}
|
||||
|
||||
BackEnd.displayName = 'BackEnd';
|
||||
BackEnd.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
@ -1,5 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
// Package Utilities
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Grid, Col, Row } from '@freecodecamp/react-bootstrap';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
@ -7,8 +9,13 @@ import { graphql } from 'gatsby';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { createSelector } from 'reselect';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
import { ChallengeNode } from '../../../../redux/prop-types';
|
||||
// Local Utilities
|
||||
import {
|
||||
ChallengeNodeType,
|
||||
ChallengeMetaType
|
||||
} from '../../../../redux/prop-types';
|
||||
import {
|
||||
challengeMounted,
|
||||
isChallengeCompletedSelector,
|
||||
@ -16,9 +23,7 @@ import {
|
||||
openModal,
|
||||
updateSolutionFormValues
|
||||
} from '../../redux';
|
||||
|
||||
import { getGuideUrl } from '../../utils';
|
||||
|
||||
import LearnLayout from '../../../../components/layouts/Learn';
|
||||
import ChallengeTitle from '../../components/Challenge-Title';
|
||||
import ChallengeDescription from '../../components/Challenge-Description';
|
||||
@ -29,14 +34,15 @@ import CompletionModal from '../../components/CompletionModal';
|
||||
import HelpModal from '../../components/HelpModal';
|
||||
import Hotkeys from '../../components/Hotkeys';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
isChallengeCompletedSelector,
|
||||
isChallengeCompleted => ({
|
||||
(isChallengeCompleted: boolean) => ({
|
||||
isChallengeCompleted
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
updateChallengeMeta,
|
||||
@ -47,24 +53,28 @@ const mapDispatchToProps = dispatch =>
|
||||
dispatch
|
||||
);
|
||||
|
||||
const propTypes = {
|
||||
challengeMounted: PropTypes.func.isRequired,
|
||||
data: PropTypes.shape({
|
||||
challengeNode: ChallengeNode
|
||||
}),
|
||||
isChallengeCompleted: PropTypes.bool,
|
||||
openCompletionModal: PropTypes.func.isRequired,
|
||||
pageContext: PropTypes.shape({
|
||||
challengeMeta: PropTypes.object
|
||||
}),
|
||||
t: PropTypes.func.isRequired,
|
||||
updateChallengeMeta: PropTypes.func.isRequired,
|
||||
updateSolutionFormValues: PropTypes.func.isRequired
|
||||
};
|
||||
// Types
|
||||
interface ProjectProps {
|
||||
challengeMounted: (arg0: string) => void;
|
||||
data: { challengeNode: ChallengeNodeType };
|
||||
isChallengeCompleted: boolean;
|
||||
openCompletionModal: () => void;
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMetaType;
|
||||
};
|
||||
t: (arg0: string) => string;
|
||||
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
|
||||
updateSolutionFormValues: () => void;
|
||||
}
|
||||
|
||||
class Project extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
// Component
|
||||
class Project extends Component<ProjectProps> {
|
||||
static displayName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _container: any;
|
||||
|
||||
constructor(props: ProjectProps) {
|
||||
super(props);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
@ -86,7 +96,7 @@ class Project extends Component {
|
||||
this._container.focus();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: ProjectProps): void {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: { title: prevTitle }
|
||||
@ -111,7 +121,11 @@ class Project extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit({ isShouldCompletionModalOpen }) {
|
||||
handleSubmit({
|
||||
isShouldCompletionModalOpen
|
||||
}: {
|
||||
isShouldCompletionModalOpen: boolean;
|
||||
}): void {
|
||||
if (isShouldCompletionModalOpen) {
|
||||
this.props.openCompletionModal();
|
||||
}
|
||||
@ -143,7 +157,7 @@ class Project extends Component {
|
||||
|
||||
return (
|
||||
<Hotkeys
|
||||
innerRef={c => (this._container = c)}
|
||||
innerRef={(c: HTMLElement | null) => (this._container = c)}
|
||||
nextChallengePath={nextChallengePath}
|
||||
prevChallengePath={prevChallengePath}
|
||||
>
|
||||
@ -154,6 +168,8 @@ class Project extends Component {
|
||||
<Grid>
|
||||
<Row>
|
||||
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<ChallengeTitle
|
||||
block={block}
|
||||
@ -167,6 +183,7 @@ class Project extends Component {
|
||||
<SolutionForm
|
||||
challengeType={challengeType}
|
||||
description={description}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
onSubmit={this.handleSubmit}
|
||||
updateSolutionForm={updateSolutionFormValues}
|
||||
/>
|
||||
@ -174,6 +191,8 @@ class Project extends Component {
|
||||
guideUrl={getGuideUrl({ forumTopicId, title })}
|
||||
/>
|
||||
<br />
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
</Col>
|
||||
<CompletionModal
|
||||
@ -191,7 +210,6 @@ class Project extends Component {
|
||||
}
|
||||
|
||||
Project.displayName = 'Project';
|
||||
Project.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
@ -1,6 +1,5 @@
|
||||
// Package Utilities
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Grid, Col, Row } from '@freecodecamp/react-bootstrap';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
@ -10,10 +9,14 @@ import YouTube from 'react-youtube';
|
||||
import { createSelector } from 'reselect';
|
||||
import { ObserveKeys } from 'react-hotkeys';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
// Local Utilities
|
||||
import PrismFormatted from '../components/PrismFormatted';
|
||||
import { ChallengeNode } from '../../../redux/prop-types';
|
||||
import {
|
||||
ChallengeNodeType,
|
||||
ChallengeMetaType
|
||||
} from '../../../redux/prop-types';
|
||||
import LearnLayout from '../../../components/layouts/Learn';
|
||||
import ChallengeTitle from '../components/Challenge-Title';
|
||||
import ChallengeDescription from '../components/Challenge-Description';
|
||||
@ -35,11 +38,11 @@ import './show.css';
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
isChallengeCompletedSelector,
|
||||
isChallengeCompleted => ({
|
||||
(isChallengeCompleted: boolean) => ({
|
||||
isChallengeCompleted
|
||||
})
|
||||
);
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
updateChallengeMeta,
|
||||
@ -50,26 +53,36 @@ const mapDispatchToProps = dispatch =>
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Proptypes
|
||||
const propTypes = {
|
||||
challengeMounted: PropTypes.func.isRequired,
|
||||
data: PropTypes.shape({
|
||||
challengeNode: ChallengeNode
|
||||
}),
|
||||
description: PropTypes.string,
|
||||
isChallengeCompleted: PropTypes.bool,
|
||||
openCompletionModal: PropTypes.func.isRequired,
|
||||
pageContext: PropTypes.shape({
|
||||
challengeMeta: PropTypes.object
|
||||
}),
|
||||
t: PropTypes.func.isRequired,
|
||||
updateChallengeMeta: PropTypes.func.isRequired,
|
||||
updateSolutionFormValues: PropTypes.func.isRequired
|
||||
};
|
||||
// Types
|
||||
interface ShowVideoProps {
|
||||
challengeMounted: (arg0: string) => void;
|
||||
data: { challengeNode: ChallengeNodeType };
|
||||
description: string;
|
||||
isChallengeCompleted: boolean;
|
||||
openCompletionModal: () => void;
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMetaType;
|
||||
};
|
||||
t: (arg0: string) => string;
|
||||
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
|
||||
updateSolutionFormValues: () => void;
|
||||
}
|
||||
|
||||
interface ShowVideoState {
|
||||
subtitles: string;
|
||||
downloadURL: string | null;
|
||||
selectedOption: number | null;
|
||||
answer: number;
|
||||
showWrong: boolean;
|
||||
videoIsLoaded: boolean;
|
||||
}
|
||||
|
||||
// Component
|
||||
class Project extends Component {
|
||||
constructor(props) {
|
||||
class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
|
||||
static displayName: string;
|
||||
private _container: HTMLElement | null | undefined;
|
||||
|
||||
constructor(props: ShowVideoProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
subtitles: '',
|
||||
@ -83,7 +96,7 @@ class Project extends Component {
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
componentDidMount(): void {
|
||||
const {
|
||||
challengeMounted,
|
||||
data: {
|
||||
@ -99,10 +112,9 @@ class Project extends Component {
|
||||
helpCategory
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
this._container.focus();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: ShowVideoProps): void {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: { title: prevTitle }
|
||||
@ -127,7 +139,7 @@ class Project extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(solution, openCompletionModal) {
|
||||
handleSubmit(solution: number, openCompletionModal: () => void) {
|
||||
if (solution - 1 === this.state.selectedOption) {
|
||||
this.setState({
|
||||
showWrong: false
|
||||
@ -140,7 +152,9 @@ class Project extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = changeEvent => {
|
||||
handleOptionChange = (
|
||||
changeEvent: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
this.setState({
|
||||
showWrong: false,
|
||||
selectedOption: parseInt(changeEvent.target.value, 10)
|
||||
@ -181,7 +195,7 @@ class Project extends Component {
|
||||
executeChallenge={() => {
|
||||
this.handleSubmit(solution, openCompletionModal);
|
||||
}}
|
||||
innerRef={c => (this._container = c)}
|
||||
innerRef={(c: HTMLElement | null) => (this._container = c)}
|
||||
nextChallengePath={nextChallengePath}
|
||||
prevChallengePath={prevChallengePath}
|
||||
>
|
||||
@ -191,6 +205,8 @@ class Project extends Component {
|
||||
/>
|
||||
<Grid>
|
||||
<Row>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<ChallengeTitle
|
||||
block={block}
|
||||
@ -222,6 +238,7 @@ class Project extends Component {
|
||||
width: 'auto',
|
||||
height: 'auto'
|
||||
}}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
videoId={videoId}
|
||||
/>
|
||||
<i>
|
||||
@ -243,6 +260,8 @@ class Project extends Component {
|
||||
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
<ChallengeDescription description={description} />
|
||||
<PrismFormatted className={'line-numbers'} text={text} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<ObserveKeys>
|
||||
<div className='video-quiz-options'>
|
||||
@ -272,6 +291,8 @@ class Project extends Component {
|
||||
))}
|
||||
</div>
|
||||
</ObserveKeys>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<div
|
||||
style={{
|
||||
@ -284,6 +305,8 @@ class Project extends Component {
|
||||
<span>{t('learn.check-answer')}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer />
|
||||
<Button
|
||||
block={true}
|
||||
@ -295,6 +318,8 @@ class Project extends Component {
|
||||
>
|
||||
{t('buttons.check-answer')}
|
||||
</Button>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Spacer size={2} />
|
||||
</Col>
|
||||
<CompletionModal
|
||||
@ -310,13 +335,12 @@ class Project extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
Project.displayName = 'Project';
|
||||
Project.propTypes = propTypes;
|
||||
ShowVideo.displayName = 'ShowVideo';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withTranslation()(Project));
|
||||
)(withTranslation()(ShowVideo));
|
||||
|
||||
export const query = graphql`
|
||||
query VideoChallenge($slug: String!) {
|
@ -5,19 +5,19 @@ const { viewTypes } = require('../challengeTypes');
|
||||
|
||||
const backend = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/projects/backend/Show.js'
|
||||
'../../src/templates/Challenges/projects/backend/Show.tsx'
|
||||
);
|
||||
const classic = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/classic/Show.js'
|
||||
'../../src/templates/Challenges/classic/Show.tsx'
|
||||
);
|
||||
const frontend = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/projects/frontend/Show.js'
|
||||
'../../src/templates/Challenges/projects/frontend/Show.tsx'
|
||||
);
|
||||
const codeally = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/codeally/show.js'
|
||||
'../../src/templates/Challenges/codeally/show.tsx'
|
||||
);
|
||||
const intro = path.resolve(
|
||||
__dirname,
|
||||
@ -29,7 +29,7 @@ const superBlockIntro = path.resolve(
|
||||
);
|
||||
const video = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/video/Show.js'
|
||||
'../../src/templates/Challenges/video/Show.tsx'
|
||||
);
|
||||
|
||||
const views = {
|
||||
|
Reference in New Issue
Block a user