refactor: display solutions (#45139)

* refactor: re-organise show-project-links

* refactor: update ChallengeFile's declared shape

* fix: handle missing challenge solution

* refactor: use display function for Certification

* refactor: use display function for TimeLine

* refactor: use common component for timeline + cert

* fix: handle legacy solutions

* refactor: use widget for certifications

* refactor: reorganise ShowDisplayWidget

* refactor: remove unused ids

* test: pass dataCy, not projectTitle, to widget

* chore: kebabify

* revert: add id back for dropdown

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

* revert: add the ids back

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2022-02-16 22:48:22 +01:00
committed by GitHub
parent 92778f1b2f
commit b223cdd255
15 changed files with 285 additions and 210 deletions

View File

@ -551,6 +551,7 @@
"heading-legacy-full-stack": "As part of this Legacy Full Stack certification, {{user}} completed the following certifications:",
"heading": "As part of this certification, {{user}} built the following projects and got all automated test suites to pass:",
"solution": "solution",
"no-solution": "error displaying solution, email support@freeCodeCamp.org to get help.",
"source": "source",
"footnote": "If you suspect that any of these projects violate the <2>academic honesty policy</2>, please <5>report this to our team</5>.",
"title": {

View File

@ -10,7 +10,7 @@ import {
legacyProjectMap
} from '../resources/cert-and-project-map';
import { maybeUrlRE } from '../utils';
import { SolutionDisplayWidget } from '../components/solution-display-widget';
interface ShowProjectLinksProps {
certName: string;
@ -52,8 +52,8 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
return null;
}
const { solution, githubLink, challengeFiles } = completedProject;
const onClickHandler = () =>
const { solution, challengeFiles } = completedProject;
const showFilesSolution = () =>
setSolutionState({
projectTitle,
challengeFiles,
@ -61,46 +61,13 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
isOpen: true
});
if (challengeFiles?.length) {
return (
<button
className='project-link-button-override'
data-cy={`${projectTitle} solution`}
onClick={onClickHandler}
>
{t('certification.project.solution')}
</button>
);
}
if (githubLink) {
return (
<>
<a href={solution ?? ''} rel='noopener noreferrer' target='_blank'>
{t('certification.project.solution')}
</a>
,{' '}
<a href={githubLink} rel='noopener noreferrer' target='_blank'>
{t('certification.project.source')}
</a>
</>
);
}
if (maybeUrlRE.test(solution ?? '')) {
return (
<a
className='btn-invert'
href={solution ?? ''}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.solution')}
</a>
);
}
return (
<button className='project-link-button-override' onClick={onClickHandler}>
{t('certification.project.solution')}
</button>
<SolutionDisplayWidget
completedChallenge={completedProject}
dataCy={`${projectTitle} solution`}
displayContext='certification'
showFilesSolution={showFilesSolution}
></SolutionDisplayWidget>
);
};

View File

@ -8,7 +8,7 @@ import { createSelector } from 'reselect';
import envData from '../../../config/env.json';
import { createFlashMessage } from '../components/Flash/redux';
import { Loader, Spacer } from '../components/helpers';
import Certification from '../components/settings/Certification';
import Certification from '../components/settings/certification';
import About from '../components/settings/about';
import DangerZone from '../components/settings/danger-zone';
import Email from '../components/settings/email';

View File

@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react';
import { useStaticQuery } from 'gatsby';
import React from 'react';
import TimeLine from './TimeLine';
import TimeLine from './time-line';
beforeEach(() => {
// @ts-ignore

View File

@ -1,11 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method */
import {
Button,
Modal,
Table,
DropdownButton,
MenuItem
} from '@freecodecamp/react-bootstrap';
import { Button, Modal, Table } from '@freecodecamp/react-bootstrap';
import Loadable from '@loadable/component';
import { useStaticQuery, graphql } from 'gatsby';
import { reverse, sortBy } from 'lodash-es';
@ -21,8 +15,8 @@ import {
} from '../../../../../utils';
import CertificationIcon from '../../../assets/icons/certification-icon';
import { ChallengeFiles, CompletedChallenge } from '../../../redux/prop-types';
import { maybeUrlRE } from '../../../utils';
import { FullWidthRow, Link } from '../../helpers';
import { SolutionDisplayWidget } from '../../solution-display-widget';
import TimelinePagination from './timeline-pagination';
import './timeline.css';
@ -53,7 +47,6 @@ function TimelineInner({
idToNameMap,
sortedTimeline,
totalPages,
completedMap,
t,
username
@ -96,73 +89,20 @@ function TimelineInner({
}
function renderViewButton(
id: string,
challengeFiles: ChallengeFiles,
githubLink?: string,
solution?: string | null
completedChallenge: CompletedChallenge
): React.ReactNode {
if (challengeFiles?.length) {
const { id, solution, challengeFiles } = completedChallenge;
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
id={`btn-for-${id}`}
onClick={() => viewSolution(id, solution, challengeFiles)}
>
{t('buttons.show-code')}
</Button>
<SolutionDisplayWidget
completedChallenge={completedChallenge}
showFilesSolution={() => viewSolution(id, solution, challengeFiles)}
displayContext={'timeline'}
></SolutionDisplayWidget>
);
} else if (githubLink) {
return (
<div className='solutions-dropdown'>
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title='View'
>
<MenuItem
bsStyle='primary'
href={solution}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.frontend')}
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.backend')}
</MenuItem>
</DropdownButton>
</div>
);
} else if (solution && maybeUrlRE.test(solution)) {
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
href={solution}
id={`btn-for-${id}`}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.view')}
</Button>
);
} else {
return null;
}
}
function renderCompletion(completed: CompletedChallenge): JSX.Element {
const { id, challengeFiles, githubLink, solution } = completed;
const { id } = completed;
const completedDate = new Date(completed.completedDate);
// @ts-expect-error idToNameMap is not a <string, string> Map...
const { challengeTitle, challengePath, certPath } = idToNameMap.get(id);
@ -181,7 +121,7 @@ function TimelineInner({
<Link to={challengePath as string}>{challengeTitle}</Link>
)}
</td>
<td>{renderViewButton(id, challengeFiles, githubLink, solution)}</td>
<td>{renderViewButton(completed)}</td>
<td className='text-center'>
<time dateTime={completedDate.toISOString()}>
{completedDate.toLocaleString([localeCode, 'en-US'], {

View File

@ -5,7 +5,7 @@ import { TFunction, useTranslation } from 'react-i18next';
import { FullWidthRow, Link, Spacer } from '../helpers';
import { User } from './../../redux/prop-types';
import Timeline from './components/TimeLine';
import Timeline from './components/time-line';
import Camper from './components/camper';
import Certifications from './components/certifications';
import HeatMap from './components/heat-map';

View File

@ -1,9 +1,4 @@
import {
Table,
Button,
DropdownButton,
MenuItem
} from '@freecodecamp/react-bootstrap';
import { Table, Button } from '@freecodecamp/react-bootstrap';
import { Link, navigate } from 'gatsby';
import { find, first } from 'lodash-es';
import PropTypes from 'prop-types';
@ -16,12 +11,10 @@ import {
projectMap,
legacyProjectMap
} from '../../resources/cert-and-project-map';
import { maybeUrlRE } from '../../utils';
import { FlashMessages } from '../Flash/redux/flash-messages';
import ProjectModal from '../SolutionViewer/ProjectModal';
import { FullWidthRow, Spacer } from '../helpers';
import { SolutionDisplayWidget } from '../solution-display-widget';
import SectionHeader from './section-header';
import './certification.css';
@ -163,7 +156,7 @@ export class CertificationSettings extends Component {
getUserIsCertMap = () => isCertMapSelector(this.props);
getProjectSolution = (projectId, projectTitle) => {
const { completedChallenges, t } = this.props;
const { completedChallenges } = this.props;
const completedProject = find(
completedChallenges,
({ id }) => projectId === id
@ -171,7 +164,7 @@ export class CertificationSettings extends Component {
if (!completedProject) {
return null;
}
const { solution, githubLink, challengeFiles } = completedProject;
const { solution, challengeFiles } = completedProject;
const onClickHandler = () =>
this.setState({
solutionViewer: {
@ -181,75 +174,14 @@ export class CertificationSettings extends Component {
isOpen: true
}
});
if (challengeFiles?.length) {
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
data-cy={projectTitle}
id={`btn-for-${projectId}`}
onClick={onClickHandler}
>
{t('buttons.show-code')}
</Button>
);
}
if (githubLink) {
return (
<div className='solutions-dropdown'>
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${projectId}`}
title='Show Solutions'
>
<MenuItem
bsStyle='primary'
href={solution}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.frontend')}
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.backend')}
</MenuItem>
</DropdownButton>
</div>
);
}
if (maybeUrlRE.test(solution)) {
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
href={solution}
id={`btn-for-${projectId}`}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.show-solution')}
</Button>
);
}
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
id={`btn-for-${projectId}`}
onClick={onClickHandler}
>
{t('buttons.show-code')}
</Button>
<SolutionDisplayWidget
completedChallenge={completedProject}
dataCy={projectTitle}
showFilesSolution={onClickHandler}
displayContext={'settings'}
></SolutionDisplayWidget>
);
};

View File

@ -3,7 +3,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from '../../redux/createStore';
import { CertificationSettings } from './Certification';
import { CertificationSettings } from './certification';
jest.mock('../../analytics');

View File

@ -0,0 +1,141 @@
import {
Button,
DropdownButton,
MenuItem
} from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CompletedChallenge } from '../../redux/prop-types';
import { getSolutionDisplayType } from '../../utils/solution-display-type';
interface Props {
completedChallenge: CompletedChallenge;
dataCy?: string;
showFilesSolution: () => void;
displayContext: 'timeline' | 'settings' | 'certification';
}
export function SolutionDisplayWidget({
completedChallenge,
dataCy,
showFilesSolution,
displayContext
}: Props) {
const { id, solution, githubLink } = completedChallenge;
const { t } = useTranslation();
const dropdownTitle =
displayContext === 'settings' ? 'Show Solutions' : 'View';
const projectLinkText =
displayContext === 'settings'
? t('buttons.show-solution')
: t('buttons.view');
const ShowFilesSolutionForCertification = (
<button
className='project-link-button-override'
data-cy={dataCy}
onClick={showFilesSolution}
>
{t('certification.project.solution')}
</button>
);
const ShowProjectAndGithubLinkForCertification = (
<>
<a href={solution ?? ''} rel='noopener noreferrer' target='_blank'>
{t('certification.project.solution')}
</a>
,{' '}
<a href={githubLink} rel='noopener noreferrer' target='_blank'>
{t('certification.project.source')}
</a>
</>
);
const ShowProjectLinkForCertification = (
<a
className='btn-invert'
href={solution ?? ''}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.solution')}
</a>
);
const MissingSolutionComponentForCertification = (
<>{t('certification.project.no-solution')}</>
);
const ShowFilesSolution = (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
data-cy={dataCy}
id={`btn-for-${id}`}
onClick={showFilesSolution}
>
{t('buttons.show-code')}
</Button>
);
const ShowProjectAndGithubLinks = (
<div className='solutions-dropdown'>
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title={dropdownTitle}
>
<MenuItem
bsStyle='primary'
href={solution}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.frontend')}
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.backend')}
</MenuItem>
</DropdownButton>
</div>
);
const ShowProjectLink = (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
href={solution}
id={`btn-for-${id}`}
rel='noopener noreferrer'
target='_blank'
>
{projectLinkText}
</Button>
);
const MissingSolutionComponent =
displayContext === 'settings' ? (
<>{t('certification.project.no-solution')}</>
) : null;
const displayComponents =
displayContext === 'certification'
? {
showFilesSolution: ShowFilesSolutionForCertification,
showProjectAndGitHubLinks: ShowProjectAndGithubLinkForCertification,
showProjectLink: ShowProjectLinkForCertification,
none: MissingSolutionComponentForCertification
}
: {
showFilesSolution: ShowFilesSolution,
showProjectAndGitHubLinks: ShowProjectAndGithubLinks,
showProjectLink: ShowProjectLink,
none: MissingSolutionComponent
};
return displayComponents[getSolutionDisplayType(completedChallenge)];
}

View File

@ -363,7 +363,7 @@ export type ChallengeFile = {
seed: string;
contents: string;
id: string;
history: [[string], string];
history: string[];
};
export type ChallengeFiles = ChallengeFile[] | null;

View File

@ -0,0 +1,39 @@
import {challengeFiles} from '../../../../utils/__fixtures__/challenges';
const baseChallenge = {
id: '1',
completedDate: 1,
challengeFiles: []
}
export const onlySolution = {
...baseChallenge,
solution: 'https://some-url.com'
}
export const legacySolution = {
...baseChallenge,
solution: 'var x = 1;'
}
export const bothLinks = {
...baseChallenge,
githubLink: 'https://some.thing',
solution: 'https://some-url.com'
}
export const withChallenges = {
...bothLinks,
challengeFiles
}
export const onlyGithubLink = {
...baseChallenge,
githubLink: 'https://some.thing'
}
export const invalidGithubLink = {
...baseChallenge,
githubLink: 'something',
solution: 'https://some-url.com'
}

View File

@ -0,0 +1,29 @@
import {
bothLinks,
legacySolution,
invalidGithubLink,
onlyGithubLink,
onlySolution,
withChallenges
} from './__fixtures/completed-challenges';
import { getSolutionDisplayType } from './solution-display-type';
describe('getSolutionDisplayType', () => {
it('should handle missing solutions', () => {
expect(getSolutionDisplayType(onlyGithubLink)).toBe('none');
});
it('should handle legacy solutions', () => {
expect(getSolutionDisplayType(legacySolution)).toBe('showFilesSolution');
});
it('should handle solutions with files', () => {
expect(getSolutionDisplayType(withChallenges)).toBe('showFilesSolution');
});
it('should handle solutions with a single valid url', () => {
expect.assertions(2);
expect(getSolutionDisplayType(onlySolution)).toBe('showProjectLink');
expect(getSolutionDisplayType(invalidGithubLink)).toBe('showProjectLink');
});
it('should handle solutions with both links', () => {
expect(getSolutionDisplayType(bothLinks)).toBe('showProjectAndGitHubLinks');
});
});

View File

@ -0,0 +1,16 @@
import type { CompletedChallenge } from '../redux/prop-types';
import { maybeUrlRE } from '.';
export const getSolutionDisplayType = ({
solution,
githubLink,
challengeFiles
}: CompletedChallenge) => {
if (challengeFiles?.length) return 'showFilesSolution';
if (!solution) return 'none';
// Some of the user records still have JavaScript project solutions stored as
// solution strings
if (!maybeUrlRE.test(solution)) return 'showFilesSolution';
if (maybeUrlRE.test(githubLink ?? '')) return 'showProjectAndGitHubLinks';
return 'showProjectLink';
};

View File

@ -18,7 +18,7 @@ const fileJoi = Joi.object().keys({
seed: Joi.string().allow(''),
contents: Joi.string().allow(''),
id: Joi.string().allow(''),
history: [Joi.array().items(Joi.string().allow('')), Joi.string().allow('')]
history: Joi.array().items(Joi.string().allow(''))
});
const schema = Joi.object()

View File

@ -1,5 +1,8 @@
exports.challengeFiles = [
import { ChallengeFile } from "../../client/src/redux/prop-types";
export const challengeFiles: ChallengeFile[] = [
{
id: '1',
contents: 'some css',
error: null,
ext: 'css',
@ -7,11 +10,13 @@ exports.challengeFiles = [
history: ['styles.css'],
fileKey: 'stylescss',
name: 'styles',
path: 'styles.css',
seed: 'some css',
tail: ''
tail: '',
editableRegionBoundaries: [],
usesMultifileEditor: true,
},
{
id: '2',
contents: 'some html',
error: null,
ext: 'html',
@ -19,11 +24,13 @@ exports.challengeFiles = [
history: ['index.html'],
fileKey: 'indexhtml',
name: 'index',
path: 'index.html',
seed: 'some html',
tail: ''
tail: '',
editableRegionBoundaries: [],
usesMultifileEditor: true,
},
{
id: '3',
contents: 'some js',
error: null,
ext: 'js',
@ -31,11 +38,13 @@ exports.challengeFiles = [
history: ['script.js'],
fileKey: 'scriptjs',
name: 'script',
path: 'script.js',
seed: 'some js',
tail: ''
tail: '',
editableRegionBoundaries: [],
usesMultifileEditor: true,
},
{
id: '4',
contents: 'some jsx',
error: null,
ext: 'jsx',
@ -43,8 +52,9 @@ exports.challengeFiles = [
history: ['index.jsx'],
fileKey: 'indexjsx',
name: 'index',
path: 'index.jsx',
seed: 'some jsx',
tail: ''
tail: '',
editableRegionBoundaries: [],
usesMultifileEditor: true,
}
];
]