feat: display multifile projects (#45220)

* refactor: DRY up certification and ProjectModal

* fix: use sensible keys for SolutionViewer

* refactor: handle legacy solutions like new ones

* refactor: correct CompletedChallenge type

* fix: store challengeType for multifile projects

* fix: use challengeType to set display type

* feat: use dropdown to display project + code

* refactor: isOpen -> showCode to avoid a clash

We need to be able both show the code and show the completed project

* refactor: remove redundant parts of projectPreview

* refactor: fix project preview types

* feat: wip, using existing modal to show project

* feat: show projects on timeline

* feat: display projects on time-line

* chore: use consistent case for GitHub

* fix(a11y): translate show solution/view

* refactor: rename showFilesSolution

* refactor: use self-closing tag

* fix: remove hardcoding (certification + timeline)

* fix: remove hardcoding (settings)

* test: supply store and mock ga

* fix: include challengeType for projects

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>

* refactor: remove space

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>

* fix: key -> filekey on challenge submission

* fix: handle submissions without files

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2022-03-03 02:49:54 +01:00
committed by GitHub
parent d1d04dbadf
commit c11bd163b2
19 changed files with 414 additions and 244 deletions

View File

@ -239,17 +239,30 @@ export function modernChallengeCompleted(req, res, next) {
const completedDate = Date.now(); const completedDate = Date.now();
const { id, files, challengeType } = req.body; const { id, files, challengeType } = req.body;
const data = { const completedChallenge = {
id, id,
files, files,
completedDate completedDate
}; };
if (challengeType === 14) { if (challengeType === 14) {
data.isManuallyApproved = false; completedChallenge.isManuallyApproved = false;
} }
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, data); // We only need to know the challenge type if it's a project. If it's a
// step or normal challenge we can avoid storing in the database.
if (
jsCertProjectIds.includes(id) ||
multiFileCertProjectIds.includes(id)
) {
completedChallenge.challengeType = challengeType;
}
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
id,
completedChallenge
);
const points = alreadyCompleted ? user.points : user.points + 1; const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) => const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => { user.updateAttributes(updateData, err => {

View File

@ -8,6 +8,7 @@
"edit": "Edit", "edit": "Edit",
"show-code": "Show Code", "show-code": "Show Code",
"show-solution": "Show Solution", "show-solution": "Show Solution",
"show-project": "Show Project",
"frontend": "Front End", "frontend": "Front End",
"backend": "Back End", "backend": "Back End",
"view": "View", "view": "View",

View File

@ -1,35 +1,44 @@
import { find, first } from 'lodash-es'; import { find, first } from 'lodash-es';
import React, { useState } from 'react'; import React, { useState } from 'react';
import '../components/layouts/project-links.css';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import ProjectModal from '../components/SolutionViewer/ProjectModal'; import ProjectModal from '../components/SolutionViewer/ProjectModal';
import { Spacer, Link } from '../components/helpers'; import { Spacer, Link } from '../components/helpers';
import { ChallengeFiles, CompletedChallenge, User } from '../redux/prop-types'; import { CompletedChallenge, User } from '../redux/prop-types';
import { import {
projectMap, projectMap,
legacyProjectMap legacyProjectMap
} from '../resources/cert-and-project-map'; } from '../resources/cert-and-project-map';
import { SolutionDisplayWidget } from '../components/solution-display-widget'; import { SolutionDisplayWidget } from '../components/solution-display-widget';
import ProjectPreviewModal from '../templates/Challenges/components/project-preview-modal';
import { openModal } from '../templates/Challenges/redux';
import '../components/layouts/project-links.css';
import { regeneratePathAndHistory } from '../../../utils/polyvinyl';
interface ShowProjectLinksProps { interface ShowProjectLinksProps {
certName: string; certName: string;
name: string; name: string;
user: User; user: User;
openModal: (arg: string) => void;
} }
type SolutionState = { type SolutionState = {
projectTitle: string; projectTitle: string;
challengeFiles: ChallengeFiles; completedChallenge: CompletedChallenge | null;
solution: CompletedChallenge['solution']; showCode: boolean;
isOpen: boolean;
}; };
const initSolutionState: SolutionState = { const initSolutionState: SolutionState = {
projectTitle: '', projectTitle: '',
challengeFiles: null, completedChallenge: null,
solution: '', showCode: false
isOpen: false };
const mapDispatchToProps = {
openModal
}; };
const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => { const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
@ -41,32 +50,41 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
const getProjectSolution = (projectId: string, projectTitle: string) => { const getProjectSolution = (projectId: string, projectTitle: string) => {
const { const {
user: { completedChallenges } user: { completedChallenges },
openModal
} = props; } = props;
const completedProject = find( const completedProject = find(
completedChallenges, completedChallenges,
({ id }) => projectId === id ({ id }) => projectId === id
) as CompletedChallenge; );
if (!completedProject) { if (!completedProject) {
return null; return null;
} }
const { solution, challengeFiles } = completedProject; const showUserCode = () =>
const showFilesSolution = () =>
setSolutionState({ setSolutionState({
projectTitle, projectTitle,
challengeFiles, completedChallenge: completedProject,
solution, showCode: true
isOpen: true
}); });
const showProjectPreview = () => {
setSolutionState({
projectTitle,
completedChallenge: completedProject,
showCode: false
});
openModal('projectPreview');
};
return ( return (
<SolutionDisplayWidget <SolutionDisplayWidget
completedChallenge={completedProject} completedChallenge={completedProject}
dataCy={`${projectTitle} solution`} dataCy={`${projectTitle} solution`}
displayContext='certification' displayContext='certification'
showFilesSolution={showFilesSolution} showUserCode={showUserCode}
showProjectPreview={showProjectPreview}
></SolutionDisplayWidget> ></SolutionDisplayWidget>
); );
}; };
@ -121,7 +139,18 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
name, name,
user: { username } user: { username }
} = props; } = props;
const { challengeFiles, isOpen, projectTitle, solution } = solutionState; const { completedChallenge, showCode, projectTitle } = solutionState;
const challengeData: CompletedChallenge | null = completedChallenge
? {
...completedChallenge,
// // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
challengeFiles:
completedChallenge?.challengeFiles?.map(regeneratePathAndHistory) ??
null
}
: null;
return ( return (
<div> <div>
{t( {t(
@ -133,17 +162,21 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
<Spacer /> <Spacer />
<ul>{renderProjectsFor(certName)}</ul> <ul>{renderProjectsFor(certName)}</ul>
<Spacer /> <Spacer />
{isOpen ? (
<ProjectModal <ProjectModal
challengeFiles={challengeFiles} challengeFiles={completedChallenge?.challengeFiles ?? null}
handleSolutionModalHide={handleSolutionModalHide} handleSolutionModalHide={handleSolutionModalHide}
isOpen={isOpen} isOpen={showCode}
projectTitle={projectTitle} projectTitle={projectTitle}
// 'solution' is theoretically never 'null', if it a JsAlgoData cert // 'solution' is theoretically never 'null', if it a JsAlgoData cert
// which is the only time we use the modal // which is the only time we use the modal
solution={solution as undefined | string} solution={completedChallenge?.solution as undefined | string}
/>
<ProjectPreviewModal
challengeData={challengeData}
closeText={t('buttons.close')}
previewTitle={projectTitle}
showProjectPreview={true}
/> />
) : null}
<Trans i18nKey='certification.project.footnote'> <Trans i18nKey='certification.project.footnote'>
If you suspect that any of these projects violate the{' '} If you suspect that any of these projects violate the{' '}
<a <a
@ -169,4 +202,4 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
ShowProjectLinks.displayName = 'ShowProjectLinks'; ShowProjectLinks.displayName = 'ShowProjectLinks';
export default ShowProjectLinks; export default connect(null, mapDispatchToProps)(ShowProjectLinks);

View File

@ -1,11 +1,11 @@
import { Button, Modal } from '@freecodecamp/react-bootstrap'; import { Button, Modal } from '@freecodecamp/react-bootstrap';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChallengeFiles } from '../../redux/prop-types'; import { CompletedChallenge } from '../../redux/prop-types';
import SolutionViewer from './SolutionViewer'; import SolutionViewer from './SolutionViewer';
type ProjectModalProps = { type ProjectModalProps = {
challengeFiles: ChallengeFiles; challengeFiles: CompletedChallenge['challengeFiles'] | null;
handleSolutionModalHide: () => void; handleSolutionModalHide: () => void;
isOpen: boolean; isOpen: boolean;
projectTitle: string; projectTitle: string;
@ -27,11 +27,9 @@ const ProjectModal = ({
onHide={handleSolutionModalHide} onHide={handleSolutionModalHide}
show={isOpen} show={isOpen}
> >
<Modal.Header className='this-one?' closeButton={true}> <Modal.Header closeButton={true}>
<Modal.Title id='solution-viewer-modal-title'> <Modal.Title id='solution-viewer-modal-title'>
{t('settings.labels.solution-for', { {t('settings.labels.solution-for', { projectTitle })}
projectTitle: projectTitle
})}
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>

View File

@ -1,66 +1,47 @@
import { Panel } from '@freecodecamp/react-bootstrap'; import { Panel } from '@freecodecamp/react-bootstrap';
import Prism from 'prismjs'; import Prism from 'prismjs';
import React from 'react'; import React from 'react';
import { ChallengeFile, ChallengeFiles } from '../../redux/prop-types'; import { ChallengeFile } from '../../redux/prop-types';
type SolutionViewerProps = { type Props = {
challengeFiles: ChallengeFiles; challengeFiles: Solution[] | null;
solution?: string; solution?: string;
}; };
type Solution = Pick<ChallengeFile, 'ext' | 'contents' | 'fileKey'>;
function SolutionViewer({ challengeFiles, solution }: Props) {
const isLegacy = !challengeFiles || !challengeFiles.length;
const solutions = isLegacy
? [
{
ext: 'js',
contents:
solution ?? '// The solution is not available for this project',
fileKey: 'script.js'
}
]
: challengeFiles;
function SolutionViewer({
challengeFiles,
solution = '// The solution is not available for this project'
}: SolutionViewerProps): JSX.Element {
return ( return (
<> <>
{challengeFiles?.length ? ( {solutions.map(({ fileKey, ext, contents }) => (
challengeFiles.map((challengeFile: ChallengeFile) => ( <Panel bsStyle='primary' className='solution-viewer' key={fileKey}>
<Panel <Panel.Heading>{ext.toUpperCase()}</Panel.Heading>
bsStyle='primary'
className='solution-viewer'
key={challengeFile}
>
<Panel.Heading>{challengeFile.ext.toUpperCase()}</Panel.Heading>
<Panel.Body> <Panel.Body>
<pre> <pre>
<code <code
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: Prism.highlight( __html: Prism.highlight(
challengeFile.contents.trim(), contents.trim(),
Prism.languages[challengeFile.ext], Prism.languages[ext],
'' ext
) )
}} }}
/> />
</pre> </pre>
</Panel.Body> </Panel.Body>
</Panel> </Panel>
)) ))}
) : (
<Panel
bsStyle='primary'
className='solution-viewer'
key={solution.slice(0, 10)}
>
<Panel.Heading>JS</Panel.Heading>
<Panel.Body>
<pre>
<code
className='language-markup'
dangerouslySetInnerHTML={{
__html: Prism.highlight(
solution.trim(),
Prism.languages.js,
'javascript'
)
}}
/>
</pre>
</Panel.Body>
</Panel>
)}
</> </>
); );
} }

View File

@ -1,12 +1,17 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
import { render, screen } from '@testing-library/react';
import { useStaticQuery } from 'gatsby'; import { useStaticQuery } from 'gatsby';
import React from 'react'; import React from 'react';
import { render, screen } from '../../../../utils/test-utils';
import { createStore } from '../../../redux/createStore';
import TimeLine from './time-line'; import TimeLine from './time-line';
jest.mock('react-ga');
const store = createStore();
beforeEach(() => { beforeEach(() => {
// @ts-ignore // @ts-expect-error
useStaticQuery.mockImplementationOnce(() => ({ useStaticQuery.mockImplementationOnce(() => ({
allChallengeNode: { allChallengeNode: {
edges: [ edges: [
@ -50,8 +55,8 @@ beforeEach(() => {
describe('<TimeLine />', () => { describe('<TimeLine />', () => {
it('Render button when only solution is present', () => { it('Render button when only solution is present', () => {
// @ts-ignore // @ts-expect-error
render(<TimeLine {...propsForOnlySolution} />); render(<TimeLine {...propsForOnlySolution} />, store);
const showViewButton = screen.getByRole('link', { name: 'buttons.view' }); const showViewButton = screen.getByRole('link', { name: 'buttons.view' });
expect(showViewButton).toHaveAttribute( expect(showViewButton).toHaveAttribute(
'href', 'href',
@ -60,8 +65,8 @@ describe('<TimeLine />', () => {
}); });
it('Render button when both githubLink and solution is present', () => { it('Render button when both githubLink and solution is present', () => {
// @ts-ignore // @ts-expect-error
render(<TimeLine {...propsForOnlySolution} />); render(<TimeLine {...propsForOnlySolution} />, store);
const menuItems = screen.getAllByRole('menuitem'); const menuItems = screen.getAllByRole('menuitem');
expect(menuItems).toHaveLength(2); expect(menuItems).toHaveLength(2);
@ -76,8 +81,8 @@ describe('<TimeLine />', () => {
}); });
it('rendering the correct button when files is present', () => { it('rendering the correct button when files is present', () => {
// @ts-ignore // @ts-expect-error
render(<TimeLine {...propsForOnlySolution} />); render(<TimeLine {...propsForOnlySolution} />, store);
const button = screen.getByText('buttons.show-code'); const button = screen.getByText('buttons.show-code');
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();

View File

@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Button, Modal, Table } from '@freecodecamp/react-bootstrap'; import { Button, Modal, Table } from '@freecodecamp/react-bootstrap';
import Loadable from '@loadable/component'; import Loadable from '@loadable/component';
import { useStaticQuery, graphql } from 'gatsby'; import { useStaticQuery, graphql } from 'gatsby';
import { reverse, sortBy } from 'lodash-es'; import { reverse, sortBy } from 'lodash-es';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { TFunction, withTranslation } from 'react-i18next'; import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import envData from '../../../../../config/env.json'; import envData from '../../../../../config/env.json';
import { langCodes } from '../../../../../config/i18n/all-langs'; import { langCodes } from '../../../../../config/i18n/all-langs';
@ -13,8 +13,11 @@ import {
getPathFromID, getPathFromID,
getTitleFromId getTitleFromId
} from '../../../../../utils'; } from '../../../../../utils';
import { regeneratePathAndHistory } from '../../../../../utils/polyvinyl';
import CertificationIcon from '../../../assets/icons/certification-icon'; import CertificationIcon from '../../../assets/icons/certification-icon';
import { ChallengeFiles, CompletedChallenge } from '../../../redux/prop-types'; import { CompletedChallenge } from '../../../redux/prop-types';
import ProjectPreviewModal from '../../../templates/Challenges/components/project-preview-modal';
import { openModal } from '../../../templates/Challenges/redux';
import { FullWidthRow, Link } from '../../helpers'; import { FullWidthRow, Link } from '../../helpers';
import { SolutionDisplayWidget } from '../../solution-display-widget'; import { SolutionDisplayWidget } from '../../solution-display-widget';
import TimelinePagination from './timeline-pagination'; import TimelinePagination from './timeline-pagination';
@ -25,6 +28,10 @@ const SolutionViewer = Loadable(
() => import('../../SolutionViewer/SolutionViewer') () => import('../../SolutionViewer/SolutionViewer')
); );
const mapDispatchToProps = {
openModal
};
const { clientLocale } = envData as { clientLocale: keyof typeof langCodes }; const { clientLocale } = envData as { clientLocale: keyof typeof langCodes };
const localeCode = langCodes[clientLocale]; const localeCode = langCodes[clientLocale];
@ -33,46 +40,53 @@ const ITEMS_PER_PAGE = 15;
interface TimelineProps { interface TimelineProps {
completedMap: CompletedChallenge[]; completedMap: CompletedChallenge[];
openModal: (arg: string) => void;
t: TFunction; t: TFunction;
username: string; username: string;
} }
interface TimelineInnerProps extends TimelineProps { interface TimelineInnerProps extends TimelineProps {
idToNameMap: Map<string, string>; idToNameMap: Map<string, NameMap>;
sortedTimeline: CompletedChallenge[]; sortedTimeline: CompletedChallenge[];
totalPages: number; totalPages: number;
} }
interface NameMap {
challengeTitle: string;
challengePath: string;
}
function TimelineInner({ function TimelineInner({
completedMap,
idToNameMap, idToNameMap,
openModal,
sortedTimeline, sortedTimeline,
totalPages, totalPages,
completedMap,
t, t,
username username
}: TimelineInnerProps) { }: TimelineInnerProps) {
const [solutionToView, setSolutionToView] = useState<string | null>(null); const [projectTitle, setProjectTitle] = useState('');
const [solutionOpen, setSolutionOpen] = useState(false); const [solutionOpen, setSolutionOpen] = useState(false);
const [pageNo, setPageNo] = useState(1); const [pageNo, setPageNo] = useState(1);
const [solution, setSolution] = useState<string | null>(null); const [completedChallenge, setCompletedChallenge] =
const [challengeFiles, setChallengeFiles] = useState<ChallengeFiles>(null); useState<CompletedChallenge | null>(null);
function viewSolution( function viewSolution(completedChallenge: CompletedChallenge): void {
id: string, setCompletedChallenge(completedChallenge);
solution_: string | undefined | null,
challengeFiles_: ChallengeFiles
): void {
setSolutionToView(id);
setSolutionOpen(true); setSolutionOpen(true);
setSolution(solution_ ?? ''); }
setChallengeFiles(challengeFiles_);
function viewProject(completedChallenge: CompletedChallenge): void {
setCompletedChallenge(completedChallenge);
setProjectTitle(
idToNameMap.get(completedChallenge.id)?.challengeTitle ?? ''
);
openModal('projectPreview');
} }
function closeSolution(): void { function closeSolution(): void {
setSolutionToView(null);
setSolutionOpen(false); setSolutionOpen(false);
setSolution(null); setCompletedChallenge(null);
setChallengeFiles(null);
} }
function firstPage(): void { function firstPage(): void {
@ -91,11 +105,11 @@ function TimelineInner({
function renderViewButton( function renderViewButton(
completedChallenge: CompletedChallenge completedChallenge: CompletedChallenge
): React.ReactNode { ): React.ReactNode {
const { id, solution, challengeFiles } = completedChallenge;
return ( return (
<SolutionDisplayWidget <SolutionDisplayWidget
completedChallenge={completedChallenge} completedChallenge={completedChallenge}
showFilesSolution={() => viewSolution(id, solution, challengeFiles)} showUserCode={() => viewSolution(completedChallenge)}
showProjectPreview={() => viewProject(completedChallenge)}
displayContext={'timeline'} displayContext={'timeline'}
></SolutionDisplayWidget> ></SolutionDisplayWidget>
); );
@ -135,11 +149,22 @@ function TimelineInner({
); );
} }
const id = solutionToView; const challengeData: CompletedChallenge | null = completedChallenge
? {
...completedChallenge,
// // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
challengeFiles:
completedChallenge?.challengeFiles?.map(regeneratePathAndHistory) ??
null
}
: null;
const id = challengeData?.id;
const startIndex = (pageNo - 1) * ITEMS_PER_PAGE; const startIndex = (pageNo - 1) * ITEMS_PER_PAGE;
const endIndex = pageNo * ITEMS_PER_PAGE; const endIndex = pageNo * ITEMS_PER_PAGE;
return ( return (
<>
<FullWidthRow> <FullWidthRow>
<h2 className='text-center'>{t('profile.timeline')}</h2> <h2 className='text-center'>{t('profile.timeline')}</h2>
{completedMap.length === 0 ? ( {completedMap.length === 0 ? (
@ -170,15 +195,14 @@ function TimelineInner({
<Modal.Header closeButton={true}> <Modal.Header closeButton={true}>
<Modal.Title id='contained-modal-title'> <Modal.Title id='contained-modal-title'>
{`${username}'s Solution to ${ {`${username}'s Solution to ${
// @ts-expect-error Need better TypeDef for this idToNameMap.get(id)?.challengeTitle ?? ''
idToNameMap.get(id).challengeTitle as string
}`} }`}
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<SolutionViewer <SolutionViewer
challengeFiles={challengeFiles} challengeFiles={challengeData.challengeFiles}
solution={solution ?? ''} solution={challengeData.solution ?? ''}
/> />
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
@ -197,11 +221,18 @@ function TimelineInner({
/> />
)} )}
</FullWidthRow> </FullWidthRow>
<ProjectPreviewModal
challengeData={challengeData}
closeText={t('buttons.close')}
previewTitle={projectTitle}
showProjectPreview={true}
/>
</>
); );
} }
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call*/ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call*/
function useIdToNameMap(): Map<string, string> { function useIdToNameMap(): Map<string, NameMap> {
const { const {
allChallengeNode: { edges } allChallengeNode: { edges }
} = useStaticQuery(graphql` } = useStaticQuery(graphql`
@ -273,4 +304,4 @@ const Timeline = (props: TimelineProps): JSX.Element => {
Timeline.displayName = 'Timeline'; Timeline.displayName = 'Timeline';
export default withTranslation()(Timeline); export default connect(null, mapDispatchToProps)(withTranslation()(Timeline));

View File

@ -5,8 +5,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import ScrollableAnchor, { configureAnchors } from 'react-scrollable-anchor'; import ScrollableAnchor, { configureAnchors } from 'react-scrollable-anchor';
import { connect } from 'react-redux';
import { regeneratePathAndHistory } from '../../../../utils/polyvinyl';
import ProjectPreviewModal from '../../templates/Challenges/components/project-preview-modal';
import { openModal } from '../../templates/Challenges/redux';
import { import {
projectMap, projectMap,
legacyProjectMap legacyProjectMap
@ -50,11 +54,16 @@ const propTypes = {
isRelationalDatabaseCertV8: PropTypes.bool, isRelationalDatabaseCertV8: PropTypes.bool,
isRespWebDesignCert: PropTypes.bool, isRespWebDesignCert: PropTypes.bool,
isSciCompPyCertV7: PropTypes.bool, isSciCompPyCertV7: PropTypes.bool,
openModal: PropTypes.func,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
username: PropTypes.string, username: PropTypes.string,
verifyCert: PropTypes.func.isRequired verifyCert: PropTypes.func.isRequired
}; };
const mapDispatchToProps = {
openModal
};
const certifications = Object.keys(projectMap); const certifications = Object.keys(projectMap);
const legacyCertifications = Object.keys(legacyProjectMap); const legacyCertifications = Object.keys(legacyProjectMap);
const isCertSelector = ({ const isCertSelector = ({
@ -161,7 +170,7 @@ export class CertificationSettings extends Component {
getUserIsCertMap = () => isCertMapSelector(this.props); getUserIsCertMap = () => isCertMapSelector(this.props);
getProjectSolution = (projectId, projectTitle) => { getProjectSolution = (projectId, projectTitle) => {
const { completedChallenges } = this.props; const { completedChallenges, openModal } = this.props;
const completedProject = find( const completedProject = find(
completedChallenges, completedChallenges,
({ id }) => projectId === id ({ id }) => projectId === id
@ -171,8 +180,7 @@ export class CertificationSettings extends Component {
} }
const { solution, challengeFiles } = completedProject; const { solution, challengeFiles } = completedProject;
const showUserCode = () =>
const onClickHandler = () =>
this.setState({ this.setState({
solutionViewer: { solutionViewer: {
projectTitle, projectTitle,
@ -182,11 +190,31 @@ export class CertificationSettings extends Component {
} }
}); });
const challengeData = completedProject
? {
...completedProject,
challengeFiles:
completedProject?.challengeFiles?.map(regeneratePathAndHistory) ??
null
}
: null;
const showProjectPreview = () => {
this.setState({
projectViewer: {
previewTitle: projectTitle,
challengeData
}
});
openModal('projectPreview');
};
return ( return (
<SolutionDisplayWidget <SolutionDisplayWidget
completedChallenge={completedProject} completedChallenge={completedProject}
dataCy={projectTitle} dataCy={projectTitle}
showFilesSolution={onClickHandler} showUserCode={showUserCode}
showProjectPreview={showProjectPreview}
displayContext={'settings'} displayContext={'settings'}
></SolutionDisplayWidget> ></SolutionDisplayWidget>
); );
@ -361,11 +389,9 @@ export class CertificationSettings extends Component {
}; };
render() { render() {
const { const { solutionViewer, projectViewer } = this.state;
solutionViewer: { challengeFiles, solution, isOpen, projectTitle }
} = this.state;
const { t } = this.props; const { t } = this.props;
return ( return (
<ScrollableAnchor id='certification-settings'> <ScrollableAnchor id='certification-settings'>
<section className='certification-settings'> <section className='certification-settings'>
@ -378,16 +404,15 @@ export class CertificationSettings extends Component {
{legacyCertifications.map(certName => {legacyCertifications.map(certName =>
this.renderCertifications(certName, legacyProjectMap) this.renderCertifications(certName, legacyProjectMap)
)} )}
{isOpen ? (
<ProjectModal <ProjectModal
challengeFiles={challengeFiles} {...solutionViewer}
handleSolutionModalHide={this.handleSolutionModalHide} handleSolutionModalHide={this.handleSolutionModalHide}
isOpen={isOpen}
projectTitle={projectTitle}
solution={solution}
t={t}
/> />
) : null} <ProjectPreviewModal
{...projectViewer}
closeText={t('buttons.close')}
showProjectPreview={true}
/>
</section> </section>
</ScrollableAnchor> </ScrollableAnchor>
); );
@ -397,4 +422,7 @@ export class CertificationSettings extends Component {
CertificationSettings.displayName = 'CertificationSettings'; CertificationSettings.displayName = 'CertificationSettings';
CertificationSettings.propTypes = propTypes; CertificationSettings.propTypes = propTypes;
export default withTranslation()(CertificationSettings); export default connect(
null,
mapDispatchToProps
)(withTranslation()(CertificationSettings));

View File

@ -11,22 +11,22 @@ import { getSolutionDisplayType } from '../../utils/solution-display-type';
interface Props { interface Props {
completedChallenge: CompletedChallenge; completedChallenge: CompletedChallenge;
dataCy?: string; dataCy?: string;
showFilesSolution: () => void; showUserCode: () => void;
showProjectPreview?: () => void;
displayContext: 'timeline' | 'settings' | 'certification'; displayContext: 'timeline' | 'settings' | 'certification';
} }
export function SolutionDisplayWidget({ export function SolutionDisplayWidget({
completedChallenge, completedChallenge,
dataCy, dataCy,
showFilesSolution, showUserCode,
showProjectPreview,
displayContext displayContext
}: Props) { }: Props) {
const { id, solution, githubLink } = completedChallenge; const { id, solution, githubLink } = completedChallenge;
const { t } = useTranslation(); const { t } = useTranslation();
const dropdownTitle = const showOrViewText =
displayContext === 'settings' ? 'Show Solutions' : 'View';
const projectLinkText =
displayContext === 'settings' displayContext === 'settings'
? t('buttons.show-solution') ? t('buttons.show-solution')
: t('buttons.view'); : t('buttons.view');
@ -35,7 +35,7 @@ export function SolutionDisplayWidget({
<button <button
className='project-link-button-override' className='project-link-button-override'
data-cy={dataCy} data-cy={dataCy}
onClick={showFilesSolution} onClick={showUserCode}
> >
{t('certification.project.solution')} {t('certification.project.solution')}
</button> </button>
@ -64,18 +64,35 @@ export function SolutionDisplayWidget({
const MissingSolutionComponentForCertification = ( const MissingSolutionComponentForCertification = (
<>{t('certification.project.no-solution')}</> <>{t('certification.project.no-solution')}</>
); );
const ShowFilesSolution = ( const ShowUserCode = (
<Button <Button
block={true} block={true}
bsStyle='primary' bsStyle='primary'
className='btn-invert' className='btn-invert'
data-cy={dataCy} data-cy={dataCy}
id={`btn-for-${id}`} id={`btn-for-${id}`}
onClick={showFilesSolution} onClick={showUserCode}
> >
{t('buttons.show-code')} {t('buttons.show-code')}
</Button> </Button>
); );
const ShowMultifileProjectSolution = (
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title={t('buttons.view')}
>
<MenuItem bsStyle='primary' onClick={showUserCode}>
{t('buttons.show-code')}
</MenuItem>
<MenuItem bsStyle='primary' onClick={showProjectPreview}>
{t('buttons.show-project')}
</MenuItem>
</DropdownButton>
);
const ShowProjectAndGithubLinks = ( const ShowProjectAndGithubLinks = (
<div className='solutions-dropdown'> <div className='solutions-dropdown'>
<DropdownButton <DropdownButton
@ -83,7 +100,7 @@ export function SolutionDisplayWidget({
bsStyle='primary' bsStyle='primary'
className='btn-invert' className='btn-invert'
id={`dropdown-for-${id}`} id={`dropdown-for-${id}`}
title={dropdownTitle} title={showOrViewText}
> >
<MenuItem <MenuItem
bsStyle='primary' bsStyle='primary'
@ -114,7 +131,7 @@ export function SolutionDisplayWidget({
rel='noopener noreferrer' rel='noopener noreferrer'
target='_blank' target='_blank'
> >
{projectLinkText} {showOrViewText}
</Button> </Button>
); );
const MissingSolutionComponent = const MissingSolutionComponent =
@ -125,14 +142,16 @@ export function SolutionDisplayWidget({
const displayComponents = const displayComponents =
displayContext === 'certification' displayContext === 'certification'
? { ? {
showFilesSolution: ShowFilesSolutionForCertification, showUserCode: ShowFilesSolutionForCertification,
showProjectAndGitHubLinks: ShowProjectAndGithubLinkForCertification, showMultifileProjectSolution: ShowMultifileProjectSolution,
showProjectAndGithubLinks: ShowProjectAndGithubLinkForCertification,
showProjectLink: ShowProjectLinkForCertification, showProjectLink: ShowProjectLinkForCertification,
none: MissingSolutionComponentForCertification none: MissingSolutionComponentForCertification
} }
: { : {
showFilesSolution: ShowFilesSolution, showUserCode: ShowUserCode,
showProjectAndGitHubLinks: ShowProjectAndGithubLinks, showMultifileProjectSolution: ShowMultifileProjectSolution,
showProjectAndGithubLinks: ShowProjectAndGithubLinks,
showProjectLink: ShowProjectLink, showProjectLink: ShowProjectLink,
none: MissingSolutionComponent none: MissingSolutionComponent
}; };

View File

@ -316,7 +316,9 @@ export type CompletedChallenge = {
githubLink?: string; githubLink?: string;
challengeType?: number; challengeType?: number;
completedDate: number; completedDate: number;
challengeFiles: ChallengeFiles; challengeFiles:
| Pick<ChallengeFile, 'contents' | 'ext' | 'fileKey' | 'name'>[]
| null;
}; };
export type Ext = 'js' | 'html' | 'css' | 'jsx'; export type Ext = 'js' | 'html' | 'css' | 'jsx';

View File

@ -16,6 +16,7 @@ import {
ChallengeFiles, ChallengeFiles,
ChallengeMeta, ChallengeMeta,
ChallengeNode, ChallengeNode,
CompletedChallenge,
ResizeProps, ResizeProps,
Test Test
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
@ -29,9 +30,7 @@ import HelpModal from '../components/help-modal';
import Notes from '../components/notes'; import Notes from '../components/notes';
import Output from '../components/output'; import Output from '../components/output';
import Preview from '../components/preview'; import Preview from '../components/preview';
import ProjectPreviewModal, { import ProjectPreviewModal from '../components/project-preview-modal';
PreviewConfig
} from '../components/project-preview-modal';
import SidePanel from '../components/side-panel'; import SidePanel from '../components/side-panel';
import VideoModal from '../components/video-modal'; import VideoModal from '../components/video-modal';
import { import {
@ -97,7 +96,10 @@ interface ShowClassicProps {
output: string[]; output: string[];
pageContext: { pageContext: {
challengeMeta: ChallengeMeta; challengeMeta: ChallengeMeta;
projectPreview: PreviewConfig & { showProjectPreview: boolean }; projectPreview: {
challengeData: CompletedChallenge;
showProjectPreview: boolean;
};
}; };
t: TFunction; t: TFunction;
tests: Test[]; tests: Test[];
@ -367,7 +369,6 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
} }
} = this.props; } = this.props;
const { description, title } = this.getChallenge(); const { description, title } = this.getChallenge();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return ( return (
challengeFiles && ( challengeFiles && (
<MultifileEditor <MultifileEditor
@ -430,7 +431,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
executeChallenge, executeChallenge,
pageContext: { pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }, challengeMeta: { nextChallengePath, prevChallengePath },
projectPreview projectPreview: { challengeData, showProjectPreview }
}, },
challengeFiles, challengeFiles,
t t
@ -494,7 +495,12 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
<HelpModal /> <HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} /> <VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal /> <ResetModal />
<ProjectPreviewModal previewConfig={projectPreview} /> <ProjectPreviewModal
challengeData={challengeData}
closeText={t('buttons.start-coding')}
previewTitle={t('learn.project-preview-title')}
showProjectPreview={showProjectPreview}
/>
</LearnLayout> </LearnLayout>
</Hotkeys> </Hotkeys>
); );

View File

@ -1,9 +1,8 @@
import { Button, Modal } from '@freecodecamp/react-bootstrap'; import { Button, Modal } from '@freecodecamp/react-bootstrap';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import type { ChallengeFile, Required } from '../../../redux/prop-types'; import type { CompletedChallenge } from '../../../redux/prop-types';
import { import {
closeModal, closeModal,
setEditorFocusability, setEditorFocusability,
@ -15,19 +14,20 @@ import Preview from './preview';
import './project-preview-modal.css'; import './project-preview-modal.css';
export interface PreviewConfig { interface ProjectPreviewMountedPayload {
challengeType: boolean; challengeData: CompletedChallenge | null;
challengeFiles: ChallengeFile[]; showProjectPreview: boolean;
required: Required;
template: string;
} }
interface Props { interface Props {
closeModal: (arg: string) => void; closeModal: (arg: string) => void;
isOpen: boolean; isOpen: boolean;
projectPreviewMounted: (previewConfig: PreviewConfig) => void; projectPreviewMounted: (payload: ProjectPreviewMountedPayload) => void;
previewConfig: PreviewConfig; challengeData: CompletedChallenge | null;
setEditorFocusability: (focusability: boolean) => void; setEditorFocusability: (focusability: boolean) => void;
showProjectPreview: boolean;
previewTitle: string;
closeText: string;
} }
const mapStateToProps = (state: unknown) => ({ const mapStateToProps = (state: unknown) => ({
@ -39,14 +39,16 @@ const mapDispatchToProps = {
projectPreviewMounted projectPreviewMounted
}; };
export function ProjectPreviewModal({ function ProjectPreviewModal({
closeModal, closeModal,
isOpen, isOpen,
projectPreviewMounted, projectPreviewMounted,
previewConfig, challengeData,
setEditorFocusability setEditorFocusability,
showProjectPreview,
previewTitle,
closeText
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (isOpen) setEditorFocusability(false); if (isOpen) setEditorFocusability(false);
}); });
@ -66,17 +68,15 @@ export function ProjectPreviewModal({
className='project-preview-modal-header fcc-modal' className='project-preview-modal-header fcc-modal'
closeButton={true} closeButton={true}
> >
<Modal.Title className='text-center'> <Modal.Title className='text-center'>{previewTitle}</Modal.Title>
{t('learn.project-preview-title')}
</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body className='project-preview-modal-body text-center'> <Modal.Body className='project-preview-modal-body text-center'>
{/* remove type assertion once frame.js has been migrated to TS */} {/* remove type assertion once frame.js has been migrated to TS */}
<Preview <Preview
previewId={projectPreviewId as string} previewId={projectPreviewId as string}
previewMounted={() => { previewMounted={() =>
projectPreviewMounted(previewConfig); projectPreviewMounted({ challengeData, showProjectPreview })
}} }
/> />
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
@ -89,7 +89,7 @@ export function ProjectPreviewModal({
setEditorFocusability(true); setEditorFocusability(true);
}} }}
> >
{t('buttons.start-coding')} {closeText}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>

View File

@ -35,10 +35,18 @@ function postChallenge(update, username) {
const saveChallenge = postUpdate$(update).pipe( const saveChallenge = postUpdate$(update).pipe(
retry(3), retry(3),
switchMap(({ points }) => { switchMap(({ points }) => {
// TODO: do this all in ajax.ts
const payloadWithClientProperties = { const payloadWithClientProperties = {
...omit(update.payload, ['files']), ...omit(update.payload, ['files'])
challengeFiles: update.payload.files ?? null
}; };
if (update.payload.files) {
payloadWithClientProperties.challengeFiles = update.payload.files.map(
({ key, ...rest }) => ({
...rest,
fileKey: key
})
);
}
return of( return of(
submitComplete({ submitComplete({
username, username,

View File

@ -27,6 +27,11 @@ export const withChallenges = {
challengeFiles challengeFiles
} }
export const multifileSolution = {
...withChallenges,
challengeType: 14
}
export const onlyGithubLink = { export const onlyGithubLink = {
...baseChallenge, ...baseChallenge,
githubLink: 'https://some.thing' githubLink: 'https://some.thing'

View File

@ -1,7 +1,8 @@
import { import {
bothLinks, bothLinks,
legacySolution,
invalidGithubLink, invalidGithubLink,
legacySolution,
multifileSolution,
onlyGithubLink, onlyGithubLink,
onlySolution, onlySolution,
withChallenges withChallenges
@ -13,10 +14,14 @@ describe('getSolutionDisplayType', () => {
expect(getSolutionDisplayType(onlyGithubLink)).toBe('none'); expect(getSolutionDisplayType(onlyGithubLink)).toBe('none');
}); });
it('should handle legacy solutions', () => { it('should handle legacy solutions', () => {
expect(getSolutionDisplayType(legacySolution)).toBe('showFilesSolution'); expect(getSolutionDisplayType(legacySolution)).toBe('showUserCode');
}); });
it('should handle solutions with files', () => { it('should handle solutions with files', () => {
expect(getSolutionDisplayType(withChallenges)).toBe('showFilesSolution'); expect.assertions(2);
expect(getSolutionDisplayType(withChallenges)).toBe('showUserCode');
expect(getSolutionDisplayType(multifileSolution)).toBe(
'showMultifileProjectSolution'
);
}); });
it('should handle solutions with a single valid url', () => { it('should handle solutions with a single valid url', () => {
expect.assertions(2); expect.assertions(2);
@ -24,6 +29,6 @@ describe('getSolutionDisplayType', () => {
expect(getSolutionDisplayType(invalidGithubLink)).toBe('showProjectLink'); expect(getSolutionDisplayType(invalidGithubLink)).toBe('showProjectLink');
}); });
it('should handle solutions with both links', () => { it('should handle solutions with both links', () => {
expect(getSolutionDisplayType(bothLinks)).toBe('showProjectAndGitHubLinks'); expect(getSolutionDisplayType(bothLinks)).toBe('showProjectAndGithubLinks');
}); });
}); });

View File

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

View File

@ -126,9 +126,7 @@ function getProjectPreviewConfig(challenge, allChallengeEdges) {
challengeType !== challengeTypes.multiFileCertProject, challengeType !== challengeTypes.multiFileCertProject,
challengeData: { challengeData: {
challengeType: lastChallenge.challengeType, challengeType: lastChallenge.challengeType,
challengeFiles: projectPreviewChallengeFiles, challengeFiles: projectPreviewChallengeFiles
required: lastChallenge.required,
template: lastChallenge.template
} }
}; };
} }

View File

@ -0,0 +1,18 @@
import React from 'react';
import { render as rtlRender } from '@testing-library/react';
import { Provider } from 'react-redux';
function render(ui, store) {
// eslint-disable-next-line react/prop-types
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return rtlRender(ui, { wrapper: Wrapper });
}
// re-export everything
// eslint-disable-next-line import/export
export * from '@testing-library/react';
// override render method
// eslint-disable-next-line import/export
export { render };

View File

@ -97,6 +97,19 @@ function setImportedFiles(importedFiles, poly) {
return newPoly; return newPoly;
} }
// This is currently only used to add back properties that are not stored in the
// database.
function regeneratePathAndHistory(poly) {
const newPath = poly.name + '.' + poly.ext;
const newPoly = {
...poly,
path: newPath,
history: [newPath]
};
checkPoly(newPoly);
return newPoly;
}
// clearHeadTail(poly: PolyVinyl) => PolyVinyl // clearHeadTail(poly: PolyVinyl) => PolyVinyl
function clearHeadTail(poly) { function clearHeadTail(poly) {
checkPoly(poly); checkPoly(poly);
@ -151,6 +164,7 @@ module.exports = {
setExt, setExt,
setImportedFiles, setImportedFiles,
compileHeadTail, compileHeadTail,
regeneratePathAndHistory,
transformContents, transformContents,
transformHeadTailAndContents transformHeadTailAndContents
}; };