diff --git a/client/src/components/Donation/DonationModal.tsx b/client/src/components/Donation/DonationModal.tsx index 4758d44331..fa126f57ae 100644 --- a/client/src/components/Donation/DonationModal.tsx +++ b/client/src/components/Donation/DonationModal.tsx @@ -40,7 +40,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ); type DonateModalProps = { - activeDonors: number; + activeDonors?: number; closeDonationModal: typeof closeDonationModal; executeGA: typeof executeGA; location: WindowLocation | undefined; diff --git a/client/src/components/formHelpers/Form.js b/client/src/components/formHelpers/Form.js deleted file mode 100644 index 4910875baa..0000000000 --- a/client/src/components/formHelpers/Form.js +++ /dev/null @@ -1,75 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Form } from 'react-final-form'; - -import { - FormFields, - BlockSaveButton, - BlockSaveWrapper, - formatUrlValues -} from './'; - -const propTypes = { - buttonText: PropTypes.string, - enableSubmit: PropTypes.bool, - formFields: PropTypes.arrayOf( - PropTypes.shape({ name: PropTypes.string, label: PropTypes.string }) - .isRequired - ).isRequired, - hideButton: PropTypes.bool, - id: PropTypes.string.isRequired, - initialValues: PropTypes.object, - options: PropTypes.shape({ - ignored: PropTypes.arrayOf(PropTypes.string), - isEditorLinkAllowed: PropTypes.bool, - required: PropTypes.arrayOf(PropTypes.string), - types: PropTypes.objectOf(PropTypes.string), - placeholders: PropTypes.shape({ - solution: PropTypes.string, - githubLink: PropTypes.string - }) - }), - submit: PropTypes.func.isRequired -}; - -function DynamicForm({ - id, - formFields, - initialValues, - options, - submit, - buttonText, - enableSubmit, - hideButton -}) { - return ( -
- submit(formatUrlValues(values, options), ...args) - } - > - {({ handleSubmit, pristine, error }) => ( - - - - {hideButton ? null : ( - - {buttonText ? buttonText : null} - - )} - - - )} - - ); -} - -DynamicForm.displayName = 'DynamicForm'; -DynamicForm.propTypes = propTypes; - -export default DynamicForm; diff --git a/client/src/components/formHelpers/Form.test.js b/client/src/components/formHelpers/Form.test.js index d091d70c5e..9562abb22c 100644 --- a/client/src/components/formHelpers/Form.test.js +++ b/client/src/components/formHelpers/Form.test.js @@ -1,7 +1,7 @@ import { render, fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import Form from './Form'; +import Form from './form'; const defaultTestProps = { buttonText: 'Submit', diff --git a/client/src/components/formHelpers/block-save-wrapper.tsx b/client/src/components/formHelpers/block-save-wrapper.tsx index 2f5776997c..79186be5de 100644 --- a/client/src/components/formHelpers/block-save-wrapper.tsx +++ b/client/src/components/formHelpers/block-save-wrapper.tsx @@ -7,7 +7,7 @@ const style = { function BlockSaveWrapper({ children }: { - children?: React.ReactElement; + children?: React.ReactElement | null; }): JSX.Element { return
{children}
; } diff --git a/client/src/components/formHelpers/FormFields.js b/client/src/components/formHelpers/form-fields.tsx similarity index 75% rename from client/src/components/formHelpers/FormFields.js rename to client/src/components/formHelpers/form-fields.tsx index 0c8c72c6ba..32b3e5ca29 100644 --- a/client/src/components/formHelpers/FormFields.js +++ b/client/src/components/formHelpers/form-fields.tsx @@ -8,35 +8,26 @@ import { } from '@freecodecamp/react-bootstrap'; import { kebabCase } from 'lodash-es'; import normalizeUrl from 'normalize-url'; -import PropTypes from 'prop-types'; import React from 'react'; import { Field } from 'react-final-form'; import { useTranslation } from 'react-i18next'; +import { FormOptions } from './form'; import { editorValidator, localhostValidator, composeValidators, fCCValidator, httpValidator -} from './FormValidators'; +} from './form-validators'; -const propTypes = { - formFields: PropTypes.arrayOf( - PropTypes.shape({ name: PropTypes.string, label: PropTypes.string }) - .isRequired - ).isRequired, - options: PropTypes.shape({ - ignored: PropTypes.arrayOf(PropTypes.string), - isEditorLinkAllowed: PropTypes.bool, - placeholders: PropTypes.objectOf(PropTypes.string), - required: PropTypes.arrayOf(PropTypes.string), - types: PropTypes.objectOf(PropTypes.string) - }) +type FormFieldsProps = { + formFields: { name: string; label: string }[]; + options: FormOptions; }; -function FormFields(props) { +function FormFields(props: FormFieldsProps): JSX.Element { const { t } = useTranslation(); - const { formFields, options = {} } = props; + const { formFields, options = {} }: FormFieldsProps = props; const { ignored = [], placeholders = {}, @@ -46,13 +37,18 @@ function FormFields(props) { isLocalLinkAllowed = false } = options; - const nullOrWarning = (value, error, isURL, name) => { - let validationError; + const nullOrWarning = ( + value: string, + error: unknown, + isURL: boolean, + name: string + ) => { + let validationError: string | undefined; if (value && isURL) { try { normalizeUrl(value, { stripWWW: false }); - } catch (err) { - validationError = err.message; + } catch (err: unknown) { + validationError = (err as { message?: string })?.message; } } const validationWarning = composeValidators( @@ -61,7 +57,9 @@ function FormFields(props) { httpValidator, isLocalLinkAllowed ? null : localhostValidator )(value); - const message = error || validationError || validationWarning; + const message: string = (error || + validationError || + validationWarning) as string; return message ? ( !ignored.includes(formField.name)) .map(({ name, label }) => ( + // TODO: verify if the value is always a string {({ input: { value, onChange }, meta: { pristine, error } }) => { const key = kebabCase(name); @@ -100,7 +99,7 @@ function FormFields(props) { required={required.includes(name)} rows={4} type={type} - value={value} + value={value as string} /> {nullOrWarning(value, !pristine && error, isURL, name)} @@ -114,6 +113,5 @@ function FormFields(props) { } FormFields.displayName = 'FormFields'; -FormFields.propTypes = propTypes; export default FormFields; diff --git a/client/src/components/formHelpers/FormValidators.js b/client/src/components/formHelpers/form-validators.tsx similarity index 52% rename from client/src/components/formHelpers/FormValidators.js rename to client/src/components/formHelpers/form-validators.tsx index 32a2cdcbaa..2010e220cc 100644 --- a/client/src/components/formHelpers/FormValidators.js +++ b/client/src/components/formHelpers/form-validators.tsx @@ -9,21 +9,26 @@ const fCCRegex = const localhostRegex = /localhost:/; const httpRegex = /http(?!s|([^s]+?localhost))/; -export const editorValidator = value => +export const editorValidator = (value: string): React.ReactElement | null => editorRegex.test(value) ? validation.editor-url : null; -export const fCCValidator = value => +export const fCCValidator = (value: string): React.ReactElement | null => fCCRegex.test(value) ? validation.own-work-url : null; -export const localhostValidator = value => +export const localhostValidator = (value: string): React.ReactElement | null => localhostRegex.test(value) ? ( validation.publicly-visible-url ) : null; -export const httpValidator = value => +export const httpValidator = (value: string): React.ReactElement | null => httpRegex.test(value) ? validation.http-url : null; -export const composeValidators = - (...validators) => - value => - validators.reduce((error, validator) => error ?? validator?.(value), null); +export type Validator = (value: string) => React.ReactElement | null; +export function composeValidators(...validators: (Validator | null)[]) { + return (value: string): ReturnType | null => + validators.reduce( + (error: ReturnType, validator) => + error ?? (validator ? validator(value) : null), + null + ); +} diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx new file mode 100644 index 0000000000..f4373b51a0 --- /dev/null +++ b/client/src/components/formHelpers/form.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Form } from 'react-final-form'; + +import { + URLValues, + ValidatedValues, + FormFields, + BlockSaveButton, + BlockSaveWrapper, + formatUrlValues +} from '../formHelpers/index'; + +export type FormOptions = { + ignored?: string[]; + isEditorLinkAllowed?: boolean; + isLocalLinkAllowed?: boolean; + required?: string[]; + types?: { [key: string]: string }; + placeholders?: { [key: string]: string }; +}; + +type FormProps = { + buttonText?: string; + enableSubmit?: boolean; + formFields: { name: string; label: string }[]; + hideButton?: boolean; + id?: string; + initialValues?: Record; + options: FormOptions; + submit: (values: ValidatedValues, ...args: unknown[]) => void; +}; + +function DynamicForm({ + id, + formFields, + initialValues, + options, + submit, + buttonText, + enableSubmit, + hideButton +}: FormProps): JSX.Element { + return ( +
{ + submit(formatUrlValues(values, options), ...args); + }} + > + {({ handleSubmit, pristine, error }) => ( + + + + {hideButton ? null : ( + + {buttonText ? buttonText : null} + + )} + + + )} + + ); +} + +DynamicForm.displayName = 'DynamicForm'; + +export default DynamicForm; diff --git a/client/src/components/formHelpers/index.js b/client/src/components/formHelpers/index.js deleted file mode 100644 index 3e692a3e6e..0000000000 --- a/client/src/components/formHelpers/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import normalizeUrl from 'normalize-url'; -import { - localhostValidator, - editorValidator, - composeValidators, - fCCValidator, - httpValidator -} from './FormValidators'; - -export { default as BlockSaveButton } from './block-save-button'; -export { default as BlockSaveWrapper } from './block-save-wrapper'; -export { default as Form } from './Form.js'; -export { default as FormFields } from './FormFields.js'; - -const normalizeOptions = { - stripWWW: false -}; - -export function formatUrlValues(values, options) { - const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options; - const validatedValues = { values: {}, errors: [], invalidValues: [] }; - const urlValues = Object.keys(values).reduce((result, key) => { - let value = values[key]; - const nullOrWarning = composeValidators( - fCCValidator, - httpValidator, - isLocalLinkAllowed ? null : localhostValidator, - key === 'githubLink' || isEditorLinkAllowed ? null : editorValidator - )(value); - if (nullOrWarning) { - validatedValues.invalidValues.push(nullOrWarning); - } - if (value && types[key] === 'url') { - try { - value = normalizeUrl(value, normalizeOptions); - } catch (err) { - // Not a valid URL for testing or submission - validatedValues.errors.push({ error: err, value }); - } - } - return { ...result, [key]: value }; - }, {}); - validatedValues.values = urlValues; - return validatedValues; -} diff --git a/client/src/components/formHelpers/index.tsx b/client/src/components/formHelpers/index.tsx new file mode 100644 index 0000000000..31931b3a10 --- /dev/null +++ b/client/src/components/formHelpers/index.tsx @@ -0,0 +1,70 @@ +import normalizeUrl from 'normalize-url'; +import { FormOptions } from './form'; +import { + localhostValidator, + editorValidator, + composeValidators, + fCCValidator, + httpValidator +} from './form-validators'; + +export { default as BlockSaveButton } from './block-save-button'; +export { default as BlockSaveWrapper } from './block-save-wrapper'; +export { default as Form } from './form'; +export { default as FormFields } from './form-fields'; + +const normalizeOptions = { + stripWWW: false +}; + +export type URLValues = { + [key: string]: string; +}; + +type ValidationError = { + error: { message?: string }; + value: string; +}; + +export type ValidatedValues = { + values: URLValues; + errors: ValidationError[]; + invalidValues: (JSX.Element | null)[]; +}; + +export function formatUrlValues( + values: URLValues, + options: FormOptions +): ValidatedValues { + const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options; + const validatedValues: ValidatedValues = { + values: {}, + errors: [], + invalidValues: [] + }; + const urlValues = Object.keys(values).reduce((result, key: string) => { + let value: string = values[key]; + const nullOrWarning: JSX.Element | null = composeValidators( + fCCValidator, + httpValidator, + isLocalLinkAllowed ? null : localhostValidator, + key === 'githubLink' || isEditorLinkAllowed ? null : editorValidator + )(value); + if (nullOrWarning) { + validatedValues.invalidValues.push(nullOrWarning); + } + if (value && types && types[key] === 'url') { + try { + value = normalizeUrl(value, normalizeOptions); + } catch (err: unknown) { + validatedValues.errors.push({ + error: err as { message?: string }, + value + }); + } + } + return { ...result, [key]: value }; + }, {}); + validatedValues.values = urlValues; + return validatedValues; +} diff --git a/client/src/templates/Challenges/projects/solution-form.tsx b/client/src/templates/Challenges/projects/solution-form.tsx index 95e14a80c3..8d0db41bf6 100644 --- a/client/src/templates/Challenges/projects/solution-form.tsx +++ b/client/src/templates/Challenges/projects/solution-form.tsx @@ -8,7 +8,7 @@ import { frontEndProject, pythonProject } from '../../../../utils/challenge-types'; -import { Form } from '../../../components/formHelpers'; +import { Form, ValidatedValues } from '../../../components/formHelpers'; interface SubmitProps { showCompletionModal: boolean; @@ -21,12 +21,6 @@ interface FormProps extends WithTranslation { updateSolutionForm: (arg0: Record) => void; } -interface ValidatedValues { - errors: string[]; - invalidValues: string[]; - values: Record; -} - export class SolutionForm extends Component { constructor(props: FormProps) { super(props); diff --git a/client/src/templates/Introduction/Intro.js b/client/src/templates/Introduction/intro.tsx similarity index 85% rename from client/src/templates/Introduction/Intro.js rename to client/src/templates/Introduction/intro.tsx index 6c64051a9f..8f3b69862d 100644 --- a/client/src/templates/Introduction/Intro.js +++ b/client/src/templates/Introduction/intro.tsx @@ -1,6 +1,5 @@ import { Grid, ListGroup, ListGroupItem } from '@freecodecamp/react-bootstrap'; import { Link, graphql } from 'gatsby'; -import PropTypes from 'prop-types'; import React from 'react'; import Helmet from 'react-helmet'; import { useTranslation } from 'react-i18next'; @@ -8,18 +7,19 @@ import { useTranslation } from 'react-i18next'; import ButtonSpacer from '../../components/helpers/button-spacer'; import FullWidthRow from '../../components/helpers/full-width-row'; import LearnLayout from '../../components/layouts/learn'; -import { MarkdownRemark, AllChallengeNode } from '../../redux/prop-types'; +import { + MarkdownRemarkType, + AllChallengeNodeType, + ChallengeNodeType +} from '../../redux/prop-types'; import './intro.css'; -const propTypes = { - data: PropTypes.shape({ - markdownRemark: MarkdownRemark, - allChallengeNode: AllChallengeNode - }) -}; - -function renderMenuItems({ edges = [] }) { +function renderMenuItems({ + edges = [] +}: { + edges?: Array<{ node: ChallengeNodeType }>; +}) { return edges .map(({ node }) => node) .map(({ title, fields: { slug } }) => ( @@ -29,7 +29,14 @@ function renderMenuItems({ edges = [] }) { )); } -function IntroductionPage({ data: { markdownRemark, allChallengeNode } }) { +function IntroductionPage({ + data: { markdownRemark, allChallengeNode } +}: { + data: { + markdownRemark: MarkdownRemarkType; + allChallengeNode: AllChallengeNodeType; + }; +}): React.FunctionComponentElement { const { t } = useTranslation(); const { html, @@ -78,7 +85,6 @@ function IntroductionPage({ data: { markdownRemark, allChallengeNode } }) { } IntroductionPage.displayName = 'IntroductionPage'; -IntroductionPage.propTypes = propTypes; export default IntroductionPage; diff --git a/client/src/templates/Introduction/SuperBlockIntro.js b/client/src/templates/Introduction/super-block-intro.tsx similarity index 80% rename from client/src/templates/Introduction/SuperBlockIntro.js rename to client/src/templates/Introduction/super-block-intro.tsx index 178ba7433d..1f89c14688 100644 --- a/client/src/templates/Introduction/SuperBlockIntro.js +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -1,16 +1,16 @@ import { Grid, Row, Col } from '@freecodecamp/react-bootstrap'; +import { WindowLocation } from '@reach/router'; import { graphql } from 'gatsby'; import { uniq } from 'lodash-es'; -import PropTypes from 'prop-types'; import React, { Fragment, useEffect, memo } from 'react'; import Helmet from 'react-helmet'; -import { withTranslation } from 'react-i18next'; +import { TFunction, withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { configureAnchors } from 'react-scrollable-anchor'; -import { bindActionCreators } from 'redux'; +import { bindActionCreators, Dispatch } from 'redux'; import { createSelector } from 'reselect'; -import DonateModal from '../../../../client/src/components/Donation/DonationModal'; +import DonateModal from '../../components/Donation/DonationModal'; import Login from '../../components/Header/components/Login'; import Map from '../../components/Map'; import { Spacer } from '../../components/helpers'; @@ -22,7 +22,11 @@ import { tryToShowDonationModal, userSelector } from '../../redux'; -import { MarkdownRemark, AllChallengeNode, User } from '../../redux/prop-types'; +import { + MarkdownRemarkType, + AllChallengeNodeType, + UserType +} from '../../redux/prop-types'; import Block from './components/Block'; import CertChallenge from './components/CertChallenge'; import SuperBlockIntro from './components/SuperBlockIntro'; @@ -30,44 +34,48 @@ import { resetExpansion, toggleBlock } from './redux'; import './intro.css'; -const propTypes = { - currentChallengeId: PropTypes.string, - data: PropTypes.shape({ - markdownRemark: MarkdownRemark, - allChallengeNode: AllChallengeNode - }), - expandedState: PropTypes.object, - fetchState: PropTypes.shape({ - pending: PropTypes.bool, - complete: PropTypes.bool, - errored: PropTypes.bool - }), - isSignedIn: PropTypes.bool, - location: PropTypes.shape({ - hash: PropTypes.string, - // TODO: state is sometimes a string - state: PropTypes.shape({ - breadcrumbBlockClick: PropTypes.string - }) - }), - resetExpansion: PropTypes.func, - signInLoading: PropTypes.bool, - t: PropTypes.func, - toggleBlock: PropTypes.func, - tryToShowDonationModal: PropTypes.func.isRequired, - user: User +type FetchState = { + pending: boolean; + complete: boolean; + errored: boolean; +}; + +type SuperBlockProp = { + currentChallengeId: string; + data: { + markdownRemark: MarkdownRemarkType; + allChallengeNode: AllChallengeNodeType; + }; + expandedState: { + [key: string]: boolean; + }; + fetchState: FetchState; + isSignedIn: boolean; + signInLoading: boolean; + location: WindowLocation<{ breadcrumbBlockClick: string }>; + resetExpansion: () => void; + t: TFunction; + toggleBlock: (arg0: string) => void; + tryToShowDonationModal: () => void; + user: UserType; }; configureAnchors({ offset: -40, scrollDuration: 0 }); -const mapStateToProps = state => { +const mapStateToProps = (state: unknown) => { return createSelector( currentChallengeIdSelector, isSignedInSelector, signInLoadingSelector, userFetchStateSelector, userSelector, - (currentChallengeId, isSignedIn, signInLoading, fetchState, user) => ({ + ( + currentChallengeId: string, + isSignedIn, + signInLoading: boolean, + fetchState: FetchState, + user: UserType + ) => ({ currentChallengeId, isSignedIn, signInLoading, @@ -77,7 +85,7 @@ const mapStateToProps = state => { )(state); }; -const mapDispatchToProps = dispatch => +const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { tryToShowDonationModal, @@ -87,7 +95,7 @@ const mapDispatchToProps = dispatch => dispatch ); -const SuperBlockIntroductionPage = props => { +const SuperBlockIntroductionPage = (props: SuperBlockProp) => { useEffect(() => { initializeExpandedState(); props.tryToShowDonationModal(); @@ -102,7 +110,7 @@ const SuperBlockIntroductionPage = props => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const getChosenBlock = () => { + const getChosenBlock = (): string => { const { data: { allChallengeNode: { edges } @@ -110,10 +118,14 @@ const SuperBlockIntroductionPage = props => { isSignedIn, currentChallengeId, location - } = props; + }: SuperBlockProp = props; // if coming from breadcrumb click - if (location.state && location.state.breadcrumbBlockClick) { + if ( + location.state && + typeof location.state === 'object' && + location.state.hasOwnProperty('breadcrumbBlockClick') + ) { return location.state.breadcrumbBlockClick; } @@ -123,7 +135,7 @@ const SuperBlockIntroductionPage = props => { return dashedBlock; } - let edge = edges[0]; + const edge = edges[0]; if (isSignedIn) { // see if currentChallenge is in this superBlock @@ -232,7 +244,6 @@ const SuperBlockIntroductionPage = props => { }; SuperBlockIntroductionPage.displayName = 'SuperBlockIntroductionPage'; -SuperBlockIntroductionPage.propTypes = propTypes; export default connect( mapStateToProps, diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js index d420621154..67532af2c3 100644 --- a/client/utils/gatsby/challenge-page-creator.js +++ b/client/utils/gatsby/challenge-page-creator.js @@ -21,11 +21,11 @@ const codeally = path.resolve( ); const intro = path.resolve( __dirname, - '../../src/templates/Introduction/Intro.js' + '../../src/templates/Introduction/intro.tsx' ); const superBlockIntro = path.resolve( __dirname, - '../../src/templates/Introduction/SuperBlockIntro.js' + '../../src/templates/Introduction/super-block-intro.tsx' ); const video = path.resolve( __dirname,