Files
freeCodeCamp/client/src/templates/Introduction/components/block.tsx
T

422 lines
14 KiB
TypeScript

import React, { Component, ReactNode } from 'react';
import type { DefaultTFuncReturn, TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import ScrollableAnchor from 'react-scrollable-anchor';
import { bindActionCreators, Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { Spacer } from '@freecodecamp/ui';
import { challengeTypes } from '../../../../../shared/config/challenge-types';
import { SuperBlocks } from '../../../../../shared/config/curriculum';
import envData from '../../../../config/env.json';
import { isAuditedSuperBlock } from '../../../../../shared/utils/is-audited';
import Caret from '../../../assets/icons/caret';
import { Link } from '../../../components/helpers';
import { completedChallengesSelector } from '../../../redux/selectors';
import { ChallengeNode, CompletedChallenge } from '../../../redux/prop-types';
import { playTone } from '../../../utils/tone';
import { makeExpandedBlockSelector, toggleBlock } from '../redux';
import { isGridBased, isProjectBased } from '../../../utils/curriculum-layout';
import { BlockLayouts, BlockTypes } from '../../../../../shared/config/blocks';
import CheckMark from './check-mark';
import Challenges from './challenges';
import BlockLabel from './block-label';
import BlockIntros from './block-intros';
import BlockHeader from './block-header';
import '../intro.css';
import './block.css';
const { curriculumLocale } = envData;
type Challenge = ChallengeNode['challenge'];
const mapStateToProps = (state: unknown, ownProps: { block: string }) => {
const expandedSelector = makeExpandedBlockSelector(ownProps.block);
return createSelector(
expandedSelector,
completedChallengesSelector,
(isExpanded: boolean, completedChallenges: CompletedChallenge[]) => ({
isExpanded,
completedChallengeIds: completedChallenges.map(({ id }) => id)
})
)(state as Record<string, unknown>);
};
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators({ toggleBlock }, dispatch);
interface BlockProps {
block: string;
blockType: BlockTypes | null;
challenges: Challenge[];
completedChallengeIds: string[];
isExpanded: boolean;
superBlock: SuperBlocks;
t: TFunction;
toggleBlock: typeof toggleBlock;
}
class Block extends Component<BlockProps> {
static displayName: string;
constructor(props: BlockProps) {
super(props);
this.handleBlockClick = this.handleBlockClick.bind(this);
}
handleBlockClick = (): void => {
const { block, toggleBlock } = this.props;
void playTone('block-toggle');
toggleBlock(block);
};
render(): ReactNode {
const {
block,
blockType,
completedChallengeIds,
challenges,
isExpanded,
superBlock,
t
} = this.props;
let completedCount = 0;
let stepNumber = 0;
const extendedChallenges = challenges.map(challenge => {
const { id } = challenge;
const isCompleted = completedChallengeIds.some(
(completedChallengeId: string) => completedChallengeId === id
);
if (isCompleted) {
completedCount++;
}
// Dialogues are interwoven with other challenges in the curriculum, but
// are not considered to be steps.
if (challenge.challengeType !== challengeTypes.dialogue) {
stepNumber++;
}
return { ...challenge, isCompleted, stepNumber };
});
const isProjectBlock = challenges.some(challenge => {
return isProjectBased(challenge.challengeType, block);
});
const isGridSuperBlock = challenges.some(challenge => {
return isGridBased(superBlock, challenge.challengeType);
});
const isAudited = isAuditedSuperBlock(curriculumLocale, superBlock);
const blockTitle = t(`intro:${superBlock}.blocks.${block}.title`);
// the real type of TFunction is the type below, because intro can be an array of strings
// type RealTypeOFTFunction = TFunction & ((key: string) => string[]);
// But changing the type will require refactoring that isn't worth it for a wrong type.
const blockIntroArr = t<string, DefaultTFuncReturn & string[]>(
`intro:${superBlock}.blocks.${block}.intro`
);
const expandText = t('intro:misc-text.expand');
const collapseText = t('intro:misc-text.collapse');
const isBlockCompleted = completedCount === extendedChallenges.length;
const percentageCompleted = Math.floor(
(completedCount / extendedChallenges.length) * 100
);
// since the Blocks are not components, we need link to exist even if it's
// not being used to render anything
const link = challenges[0]?.fields.slug || '';
const blockLayout = challenges[0]?.blockLayout;
const isGridBlock = blockLayout === BlockLayouts.ChallengeGrid;
const isEmptyBlock = !challenges.length;
const courseCompletionStatus = () => {
if (completedCount === 0) {
return t('learn.not-started');
}
if (completedCount === extendedChallenges.length) {
return t('learn.completed');
}
return `${percentageCompleted}% ${t('learn.completed')}`;
};
/**
* LegacyChallengeListBlock displays challenges in a list.
* This layout is used in backend blocks, The Odin Project blocks, and blocks in legacy certification.
* Example: https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#basic-javascript
*/
const LegacyChallengeListBlock = (
<ScrollableAnchor id={block}>
<div className={`block ${isExpanded ? 'open' : ''}`}>
<div className='block-header'>
<h3 className='big-block-title'>{blockTitle}</h3>
{blockType && <BlockLabel blockType={blockType} />}
{!isAudited && (
<div className='block-cta-wrapper'>
<Link
className='block-title-translation-cta'
to={t('links:help-translate-link-url')}
>
{t('misc.translation-pending')}
</Link>
</div>
)}
</div>
<BlockIntros intros={blockIntroArr} />
<button
aria-expanded={isExpanded}
className='map-title'
onClick={() => {
this.handleBlockClick();
}}
>
<Caret />
<div className='course-title'>
{`${isExpanded ? collapseText : expandText}`}{' '}
<span className='sr-only'>{blockTitle}</span>
</div>
<div className='map-title-completed course-title'>
<CheckMark isCompleted={isBlockCompleted} />
<span
aria-hidden='true'
className='map-completed-count'
>{`${completedCount}/${extendedChallenges.length}`}</span>
<span className='sr-only'>
,{' '}
{t('learn.challenges-completed', {
completedCount,
totalChallenges: extendedChallenges.length
})}
</span>
</div>
</button>
{isExpanded && (
<Challenges
challenges={extendedChallenges}
isProjectBlock={isProjectBlock}
/>
)}
</div>
</ScrollableAnchor>
);
/**
* ProjectListBlock displays a list of certification projects.
* This layout is used in legacy certifications.
* Example: https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#javascript-algorithms-and-data-structures-projects
*/
const ProjectListBlock = (
<ScrollableAnchor id={block}>
<div className='block'>
<div className='block-header'>
<h3 className='big-block-title'>{blockTitle}</h3>
{blockType && <BlockLabel blockType={blockType} />}
{!isAudited && (
<div className='block-cta-wrapper'>
<Link
className='block-title-translation-cta'
to={t('links:help-translate-link-url')}
>
{t('misc.translation-pending')}
</Link>
</div>
)}
</div>
<BlockIntros intros={blockIntroArr} />
<Challenges
challenges={extendedChallenges}
isProjectBlock={isProjectBlock}
/>
</div>
</ScrollableAnchor>
);
/**
* LegacyChallengeGridBlock displays challenges in a grid.
* This layout is used for step-based blocks.
* Example: https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures-v8/#learn-basic-javascript-by-building-a-role-playing-game
*/
const LegacyChallengeGridBlock = (
<ScrollableAnchor id={block}>
<div className={`block block-grid ${isExpanded ? 'open' : ''}`}>
<BlockHeader
blockDashed={block}
blockTitle={blockTitle}
blockType={blockType}
completedCount={completedCount}
courseCompletionStatus={courseCompletionStatus()}
handleClick={this.handleBlockClick}
isCompleted={isBlockCompleted}
isExpanded={isExpanded}
percentageCompleted={percentageCompleted}
/>
{isExpanded && (
<>
{!isAudited && (
<div className='tags-wrapper'>
<Link
className='cert-tag'
to={t('links:help-translate-link-url')}
>
{t('misc.translation-pending')}
</Link>
</div>
)}
<div id={`${block}-panel`}>
<BlockIntros intros={blockIntroArr} />
<Challenges
challenges={extendedChallenges}
isProjectBlock={isProjectBlock}
isGridMap={true}
blockTitle={blockTitle}
/>
</div>
</>
)}
</div>
</ScrollableAnchor>
);
/**
* LegacyLinkBlock displays the block as a single link.
* This layout is used if the block has a single challenge.
* Example: https://www.freecodecamp.org/learn/2022/responsive-web-design/#build-a-survey-form-project
*/
const LegacyLinkBlock = (
<ScrollableAnchor id={block}>
<div className='block block-grid grid-project-block'>
<div className='tags-wrapper'>
<span className='cert-tag' aria-hidden='true'>
{t('misc.certification-project')}
</span>
{!isAudited && (
<Link
className='cert-tag'
to={t('links:help-translate-link-url')}
>
{t('misc.translation-pending')}{' '}
<span className='sr-only'>
{blockTitle} {t('misc.certification-project')}
</span>
</Link>
)}
</div>
<div className='title-wrapper map-title'>
{blockType && <BlockLabel blockType={blockType} />}
<h3 className='block-grid-title'>
<Link
className='block-header'
onClick={() => {
this.handleBlockClick();
}}
to={link}
>
<CheckMark isCompleted={isBlockCompleted} />
{blockTitle}{' '}
<span className='sr-only'>
{isBlockCompleted
? `${t('misc.certification-project')}, ${t('learn.completed')}`
: `${t('misc.certification-project')}, ${t('learn.not-completed')}`}
</span>
</Link>
</h3>
</div>
<BlockIntros intros={blockIntroArr} />
</div>
</ScrollableAnchor>
);
/**
* AccordionBlock is used as the block layout in new accordion style superblocks.
*/
const AccordionBlock = (
<>
<ScrollableAnchor id={block}>
<span className='hide-scrollable-anchor'></span>
</ScrollableAnchor>
<div
className={`block block-grid challenge-grid-block ${isExpanded ? 'open' : ''}`}
>
<BlockHeader
blockDashed={block}
blockTitle={blockTitle}
blockType={blockType}
completedCount={completedCount}
courseCompletionStatus={courseCompletionStatus()}
handleClick={this.handleBlockClick}
isCompleted={isBlockCompleted}
isExpanded={isExpanded}
percentageCompleted={percentageCompleted}
blockIntroArr={blockIntroArr}
/>
{isExpanded && (
<div className='accordion-block-expanded'>
{!isAudited && (
<div className='tags-wrapper'>
<Link
className='cert-tag'
to={t('links:help-translate-link-url')}
>
{t('misc.translation-pending')}
</Link>
</div>
)}
<div
id={`${block}-panel`}
className={isGridBlock ? 'challenge-grid-block-panel' : ''}
>
<Challenges
challenges={extendedChallenges}
isProjectBlock={false}
isGridMap={isGridBlock}
blockTitle={blockTitle}
/>
</div>
</div>
)}
</div>
</>
);
const layoutToComponent = {
[BlockLayouts.ChallengeGrid]: AccordionBlock,
[BlockLayouts.ChallengeList]: AccordionBlock,
[BlockLayouts.Link]: AccordionBlock,
[BlockLayouts.ProjectList]: ProjectListBlock,
[BlockLayouts.LegacyLink]: LegacyLinkBlock,
[BlockLayouts.LegacyChallengeList]: LegacyChallengeListBlock,
[BlockLayouts.LegacyChallengeGrid]: LegacyChallengeGridBlock
};
return (
!isEmptyBlock && (
<>
{layoutToComponent[blockLayout]}
{(!isGridSuperBlock || isProjectBlock) &&
superBlock !== SuperBlocks.FullStackDeveloper && (
<Spacer size='m' />
)}
</>
)
);
}
}
Block.displayName = 'Block';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(Block));