mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-11 16:00:12 -04:00
feat(learn): create a progress indicator map (#53963)
Co-authored-by: sembauke <semboot699@gmail.com>
This commit is contained in:
97
client/src/assets/icons/completion-ribbon.tsx
Normal file
97
client/src/assets/icons/completion-ribbon.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
|
||||
interface RibbonProps {
|
||||
value: number;
|
||||
isClaimed: boolean;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export const Arrow = () => (
|
||||
<svg width={70} height={40} xmlns='http://www.w3.org/2000/svg'>
|
||||
<line
|
||||
x1={50}
|
||||
y1={0}
|
||||
x2={50}
|
||||
y2={35}
|
||||
stroke='black'
|
||||
strokeWidth={3}
|
||||
strokeDasharray='6,1.3'
|
||||
className='map-arrow-icon'
|
||||
/>
|
||||
<rect
|
||||
x={36}
|
||||
y={35}
|
||||
width={15}
|
||||
height={2}
|
||||
fill='black'
|
||||
transform='rotate(45, 50, 36)'
|
||||
className='map-arrow-icon'
|
||||
/>
|
||||
<rect
|
||||
x={49}
|
||||
y={35}
|
||||
width={15}
|
||||
height={2}
|
||||
fill='black'
|
||||
transform='rotate(-45, 50, 36)'
|
||||
className='map-arrow-icon'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RibbonIcon = ({
|
||||
value,
|
||||
isCompleted: completed,
|
||||
isClaimed
|
||||
}: RibbonProps): JSX.Element => {
|
||||
const properClassName = completed ? 'completeIcon' : 'incompleteIcon';
|
||||
const fillColor = completed ? 'black' : 'gray';
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='75%'
|
||||
height='75%'
|
||||
viewBox='0 0 45 50'
|
||||
fill='none'
|
||||
className={properClassName}
|
||||
aria-hidden='true'
|
||||
>
|
||||
{isClaimed && (
|
||||
<>
|
||||
<path
|
||||
d='M25 35.3418L35.4851 28L44.5957 41.0113L36.2658 39.7151L34.1106 48.353L25 35.3418Z'
|
||||
className='map-icon'
|
||||
/>
|
||||
<path
|
||||
d='M9.11059 29L19.5957 36.3418L10.4851 49.353L8.85418 41.0821L-4.67677e-07 42.0113L9.11059 29Z'
|
||||
className='map-icon'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<circle cx={21.9999} cy={21} r={20} fill={fillColor} />
|
||||
<circle
|
||||
cx={22.5}
|
||||
cy={21}
|
||||
r={17.5}
|
||||
fill={fillColor}
|
||||
stroke='white'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x='50%'
|
||||
y='50%'
|
||||
fontFamily='Verdana'
|
||||
color={fillColor}
|
||||
fontSize='1.0rem'
|
||||
fill='#fff'
|
||||
textAnchor='middle'
|
||||
alignmentBaseline='central'
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
RibbonIcon.displayName = 'RibbonIcon';
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import {
|
||||
SuperBlockStages,
|
||||
SuperBlocks,
|
||||
@@ -14,8 +15,24 @@ import { showUpcomingChanges } from '../../../config/env.json';
|
||||
|
||||
import './map.css';
|
||||
|
||||
import {
|
||||
isSignedInSelector,
|
||||
currentCertsSelector
|
||||
} from '../../redux/selectors';
|
||||
|
||||
import { RibbonIcon, Arrow } from '../../assets/icons/completion-ribbon';
|
||||
|
||||
import { CurrentCert, ClaimedCertifications } from '../../redux/prop-types';
|
||||
import {
|
||||
certSlugTypeMap,
|
||||
superBlockCertTypeMap
|
||||
} from '../../../../shared/config/certification-settings';
|
||||
|
||||
interface MapProps {
|
||||
forLanding?: boolean;
|
||||
isSignedIn: boolean;
|
||||
currentCerts: CurrentCert[];
|
||||
claimedCertifications?: ClaimedCertifications;
|
||||
}
|
||||
|
||||
const linkSpacingStyle = {
|
||||
@@ -31,12 +48,31 @@ const coreCurriculum = [
|
||||
...superBlockOrder[SuperBlockStages.Python]
|
||||
];
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
isSignedInSelector,
|
||||
currentCertsSelector,
|
||||
(isSignedIn: boolean, currentCerts) => ({
|
||||
isSignedIn,
|
||||
currentCerts
|
||||
})
|
||||
);
|
||||
|
||||
function MapLi({
|
||||
superBlock,
|
||||
landing = false
|
||||
landing = false,
|
||||
last = false,
|
||||
trackProgress,
|
||||
completed,
|
||||
claimed,
|
||||
index
|
||||
}: {
|
||||
superBlock: SuperBlocks;
|
||||
landing: boolean;
|
||||
last?: boolean;
|
||||
trackProgress: boolean;
|
||||
completed: boolean;
|
||||
claimed: boolean;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -44,6 +80,19 @@ function MapLi({
|
||||
data-test-label='curriculum-map-button'
|
||||
data-playwright-test-label='curriculum-map-button'
|
||||
>
|
||||
{trackProgress && (
|
||||
<>
|
||||
<div className='progress-icon'>
|
||||
<RibbonIcon
|
||||
value={index + 1}
|
||||
isCompleted={completed}
|
||||
isClaimed={claimed}
|
||||
/>
|
||||
</div>
|
||||
<div className='progression-arrow'>{!last && <Arrow />}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link className='btn link-btn btn-lg' to={`/learn/${superBlock}/`}>
|
||||
<div style={linkSpacingStyle}>
|
||||
<SuperBlockIcon className='map-icon' superBlock={superBlock} />
|
||||
@@ -56,9 +105,43 @@ function MapLi({
|
||||
);
|
||||
}
|
||||
|
||||
function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
function Map({
|
||||
forLanding = false,
|
||||
isSignedIn,
|
||||
currentCerts
|
||||
}: MapProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isTracking = (stage: SuperBlocks) =>
|
||||
![
|
||||
...superBlockOrder[SuperBlockStages.Upcoming],
|
||||
...superBlockOrder[SuperBlockStages.Extra]
|
||||
].includes(stage);
|
||||
|
||||
const isCompleted = (stage: SuperBlocks) => {
|
||||
return isSignedIn
|
||||
? Boolean(
|
||||
currentCerts?.find(
|
||||
(cert: { certSlug: string }) =>
|
||||
(certSlugTypeMap as { [key: string]: string })[cert.certSlug] ===
|
||||
(superBlockCertTypeMap as { [key: string]: string })[stage]
|
||||
)
|
||||
)
|
||||
: false;
|
||||
};
|
||||
|
||||
const isClaimed = (stage: SuperBlocks) => {
|
||||
return isSignedIn
|
||||
? Boolean(
|
||||
currentCerts?.find(
|
||||
(cert: { certSlug: string }) =>
|
||||
(certSlugTypeMap as { [key: string]: string })[cert.certSlug] ===
|
||||
(superBlockCertTypeMap as { [key: string]: string })[stage]
|
||||
)?.show
|
||||
)
|
||||
: false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='map-ui' data-test-label='curriculum-map'>
|
||||
<h2 className={forLanding ? 'big-heading' : ''}>
|
||||
@@ -66,7 +149,16 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{coreCurriculum.map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
index={i}
|
||||
claimed={isClaimed(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
last={i + 1 == coreCurriculum.length}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Spacer size='medium' />
|
||||
@@ -75,7 +167,16 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{superBlockOrder[SuperBlockStages.English].map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
claimed={isClaimed(superBlock)}
|
||||
index={i}
|
||||
last={i + 1 == superBlockOrder[SuperBlockStages.English].length}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Spacer size='medium' />
|
||||
@@ -84,7 +185,18 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{superBlockOrder[SuperBlockStages.Professional].map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
claimed={isClaimed(superBlock)}
|
||||
index={i}
|
||||
last={
|
||||
i + 1 == superBlockOrder[SuperBlockStages.Professional].length
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Spacer size='medium' />
|
||||
@@ -93,7 +205,16 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{superBlockOrder[SuperBlockStages.Extra].map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
claimed={isClaimed(superBlock)}
|
||||
index={i}
|
||||
last={i + 1 == superBlockOrder[SuperBlockStages.Extra].length}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Spacer size='medium' />
|
||||
@@ -102,7 +223,16 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{superBlockOrder[SuperBlockStages.Legacy].map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
claimed={isClaimed(superBlock)}
|
||||
index={i}
|
||||
last={i + 1 == superBlockOrder[SuperBlockStages.Legacy].length}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{showUpcomingChanges && (
|
||||
@@ -113,7 +243,18 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
</h2>
|
||||
<ul>
|
||||
{superBlockOrder[SuperBlockStages.Upcoming].map((superBlock, i) => (
|
||||
<MapLi key={i} superBlock={superBlock} landing={forLanding} />
|
||||
<MapLi
|
||||
key={i}
|
||||
superBlock={superBlock}
|
||||
landing={forLanding}
|
||||
trackProgress={isTracking(superBlock)}
|
||||
completed={isCompleted(superBlock)}
|
||||
index={i}
|
||||
claimed={isClaimed(superBlock)}
|
||||
last={
|
||||
i + 1 == superBlockOrder[SuperBlockStages.Upcoming].length
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
@@ -124,4 +265,4 @@ function Map({ forLanding = false }: MapProps): React.ReactElement {
|
||||
|
||||
Map.displayName = 'Map';
|
||||
|
||||
export default Map;
|
||||
export default connect(mapStateToProps)(Map);
|
||||
|
||||
@@ -8,9 +8,66 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.progression-arrow {
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
left: -18px;
|
||||
}
|
||||
|
||||
.map-ui ul .progress-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
margin: 0 10px 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-ui ul .progress-icon .cert-icon-outline {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.map-ui ul li .progress-icon .cert-icon-outline > svg {
|
||||
display: inline-block;
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.map-ui ul li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-ui ul li .arrow path {
|
||||
fill: var(--secondary-color);
|
||||
}
|
||||
|
||||
.map-ui ul li .progress-number {
|
||||
width: calc(42px - 0.1rem);
|
||||
height: 4rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--quaternary-background);
|
||||
color: var(--secondary-color);
|
||||
border: 0.2rem solid var(--secondary-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.map-ui .block ul {
|
||||
padding-inline-start: 6px;
|
||||
padding-inline-start: 6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,8 @@ fieldset[disabled] .btn-primary.focus {
|
||||
fill: var(--quaternary-background);
|
||||
}
|
||||
|
||||
.link-btn.btn-lg:hover .map-icon .inverted-color {
|
||||
.link-btn.btn-lg:hover .map-icon .inverted-color,
|
||||
.map-arrow-icon {
|
||||
fill: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -382,6 +383,10 @@ fieldset[disabled] .btn-primary.focus {
|
||||
margin-inline: 5px;
|
||||
}
|
||||
|
||||
.map-arrow-icon {
|
||||
stroke: var(--secondary-color);
|
||||
}
|
||||
|
||||
.cert-header-icon {
|
||||
display: block;
|
||||
width: 80px;
|
||||
@@ -568,7 +573,7 @@ blockquote .small {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
/*
|
||||
/*
|
||||
* /learn sets some default styles to all `h2`s.
|
||||
* These rules are to override the defaults and apply danger styles to `danger` modal.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user