From c11bd163b29e8b53309be726f681d5863c75c560 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Thu, 3 Mar 2022 02:49:54 +0100 Subject: [PATCH] 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> --- api-server/src/server/boot/challenge.js | 19 +- client/i18n/locales/english/translations.json | 1 + .../client-only-routes/show-project-links.tsx | 91 ++++++--- .../SolutionViewer/ProjectModal.tsx | 10 +- .../SolutionViewer/SolutionViewer.tsx | 67 +++--- .../profile/components/time-line.test.tsx | 21 +- .../profile/components/time-line.tsx | 191 ++++++++++-------- .../src/components/settings/certification.js | 68 +++++-- .../solution-display-widget/index.tsx | 47 +++-- client/src/redux/prop-types.ts | 4 +- .../src/templates/Challenges/classic/show.tsx | 20 +- .../components/project-preview-modal.tsx | 40 ++-- .../Challenges/redux/completion-epic.js | 12 +- .../utils/__fixtures/completed-challenges.ts | 5 + .../src/utils/solution-display-type.test.ts | 13 +- client/src/utils/solution-display-type.ts | 13 +- client/utils/gatsby/challenge-page-creator.js | 4 +- client/utils/test-utils.jsx | 18 ++ utils/polyvinyl.js | 14 ++ 19 files changed, 414 insertions(+), 244 deletions(-) create mode 100644 client/utils/test-utils.jsx diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index 7a35072406..dcc30ec7c1 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -239,17 +239,30 @@ export function modernChallengeCompleted(req, res, next) { const completedDate = Date.now(); const { id, files, challengeType } = req.body; - const data = { + const completedChallenge = { id, files, completedDate }; 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 updatePromise = new Promise((resolve, reject) => user.updateAttributes(updateData, err => { diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index ec8f50ba51..1341971cc6 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -8,6 +8,7 @@ "edit": "Edit", "show-code": "Show Code", "show-solution": "Show Solution", + "show-project": "Show Project", "frontend": "Front End", "backend": "Back End", "view": "View", diff --git a/client/src/client-only-routes/show-project-links.tsx b/client/src/client-only-routes/show-project-links.tsx index 3ca5a39dcb..9487a8c64f 100644 --- a/client/src/client-only-routes/show-project-links.tsx +++ b/client/src/client-only-routes/show-project-links.tsx @@ -1,35 +1,44 @@ import { find, first } from 'lodash-es'; import React, { useState } from 'react'; -import '../components/layouts/project-links.css'; import { Trans, useTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; + import ProjectModal from '../components/SolutionViewer/ProjectModal'; import { Spacer, Link } from '../components/helpers'; -import { ChallengeFiles, CompletedChallenge, User } from '../redux/prop-types'; +import { CompletedChallenge, User } from '../redux/prop-types'; import { projectMap, legacyProjectMap } from '../resources/cert-and-project-map'; 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 { certName: string; name: string; user: User; + openModal: (arg: string) => void; } type SolutionState = { projectTitle: string; - challengeFiles: ChallengeFiles; - solution: CompletedChallenge['solution']; - isOpen: boolean; + completedChallenge: CompletedChallenge | null; + showCode: boolean; }; const initSolutionState: SolutionState = { projectTitle: '', - challengeFiles: null, - solution: '', - isOpen: false + completedChallenge: null, + showCode: false +}; + +const mapDispatchToProps = { + openModal }; const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => { @@ -41,32 +50,41 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => { const getProjectSolution = (projectId: string, projectTitle: string) => { const { - user: { completedChallenges } + user: { completedChallenges }, + openModal } = props; const completedProject = find( completedChallenges, ({ id }) => projectId === id - ) as CompletedChallenge; + ); if (!completedProject) { return null; } - const { solution, challengeFiles } = completedProject; - const showFilesSolution = () => + const showUserCode = () => setSolutionState({ projectTitle, - challengeFiles, - solution, - isOpen: true + completedChallenge: completedProject, + showCode: true }); + const showProjectPreview = () => { + setSolutionState({ + projectTitle, + completedChallenge: completedProject, + showCode: false + }); + openModal('projectPreview'); + }; + return ( ); }; @@ -121,7 +139,18 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => { name, user: { username } } = 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 (
{t( @@ -133,17 +162,21 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
    {renderProjectsFor(certName)}
- {isOpen ? ( - - ) : null} + + If you suspect that any of these projects violate the{' '} { ShowProjectLinks.displayName = 'ShowProjectLinks'; -export default ShowProjectLinks; +export default connect(null, mapDispatchToProps)(ShowProjectLinks); diff --git a/client/src/components/SolutionViewer/ProjectModal.tsx b/client/src/components/SolutionViewer/ProjectModal.tsx index 670a5b991f..83dc9a2a98 100644 --- a/client/src/components/SolutionViewer/ProjectModal.tsx +++ b/client/src/components/SolutionViewer/ProjectModal.tsx @@ -1,11 +1,11 @@ import { Button, Modal } from '@freecodecamp/react-bootstrap'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ChallengeFiles } from '../../redux/prop-types'; +import { CompletedChallenge } from '../../redux/prop-types'; import SolutionViewer from './SolutionViewer'; type ProjectModalProps = { - challengeFiles: ChallengeFiles; + challengeFiles: CompletedChallenge['challengeFiles'] | null; handleSolutionModalHide: () => void; isOpen: boolean; projectTitle: string; @@ -27,11 +27,9 @@ const ProjectModal = ({ onHide={handleSolutionModalHide} show={isOpen} > - + - {t('settings.labels.solution-for', { - projectTitle: projectTitle - })} + {t('settings.labels.solution-for', { projectTitle })} diff --git a/client/src/components/SolutionViewer/SolutionViewer.tsx b/client/src/components/SolutionViewer/SolutionViewer.tsx index 8c977096ac..255d9bf607 100644 --- a/client/src/components/SolutionViewer/SolutionViewer.tsx +++ b/client/src/components/SolutionViewer/SolutionViewer.tsx @@ -1,66 +1,47 @@ import { Panel } from '@freecodecamp/react-bootstrap'; import Prism from 'prismjs'; import React from 'react'; -import { ChallengeFile, ChallengeFiles } from '../../redux/prop-types'; +import { ChallengeFile } from '../../redux/prop-types'; -type SolutionViewerProps = { - challengeFiles: ChallengeFiles; +type Props = { + challengeFiles: Solution[] | null; solution?: string; }; +type Solution = Pick; + +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 ( <> - {challengeFiles?.length ? ( - challengeFiles.map((challengeFile: ChallengeFile) => ( - - {challengeFile.ext.toUpperCase()} - - -
-                
-              
-
-
- )) - ) : ( - - JS + {solutions.map(({ fileKey, ext, contents }) => ( + + {ext.toUpperCase()}
               
             
- )} + ))} ); } diff --git a/client/src/components/profile/components/time-line.test.tsx b/client/src/components/profile/components/time-line.test.tsx index d08b0654d3..c4ab22eb84 100644 --- a/client/src/components/profile/components/time-line.test.tsx +++ b/client/src/components/profile/components/time-line.test.tsx @@ -1,12 +1,17 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ -import { render, screen } from '@testing-library/react'; import { useStaticQuery } from 'gatsby'; import React from 'react'; + +import { render, screen } from '../../../../utils/test-utils'; +import { createStore } from '../../../redux/createStore'; import TimeLine from './time-line'; +jest.mock('react-ga'); +const store = createStore(); + beforeEach(() => { - // @ts-ignore + // @ts-expect-error useStaticQuery.mockImplementationOnce(() => ({ allChallengeNode: { edges: [ @@ -50,8 +55,8 @@ beforeEach(() => { describe('', () => { it('Render button when only solution is present', () => { - // @ts-ignore - render(); + // @ts-expect-error + render(, store); const showViewButton = screen.getByRole('link', { name: 'buttons.view' }); expect(showViewButton).toHaveAttribute( 'href', @@ -60,8 +65,8 @@ describe('', () => { }); it('Render button when both githubLink and solution is present', () => { - // @ts-ignore - render(); + // @ts-expect-error + render(, store); const menuItems = screen.getAllByRole('menuitem'); expect(menuItems).toHaveLength(2); @@ -76,8 +81,8 @@ describe('', () => { }); it('rendering the correct button when files is present', () => { - // @ts-ignore - render(); + // @ts-expect-error + render(, store); const button = screen.getByText('buttons.show-code'); expect(button).toBeInTheDocument(); diff --git a/client/src/components/profile/components/time-line.tsx b/client/src/components/profile/components/time-line.tsx index df3ee76518..68409673db 100644 --- a/client/src/components/profile/components/time-line.tsx +++ b/client/src/components/profile/components/time-line.tsx @@ -1,10 +1,10 @@ -/* eslint-disable @typescript-eslint/unbound-method */ import { Button, Modal, Table } from '@freecodecamp/react-bootstrap'; import Loadable from '@loadable/component'; import { useStaticQuery, graphql } from 'gatsby'; import { reverse, sortBy } from 'lodash-es'; import React, { useMemo, useState } from 'react'; import { TFunction, withTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; import envData from '../../../../../config/env.json'; import { langCodes } from '../../../../../config/i18n/all-langs'; @@ -13,8 +13,11 @@ import { getPathFromID, getTitleFromId } from '../../../../../utils'; +import { regeneratePathAndHistory } from '../../../../../utils/polyvinyl'; 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 { SolutionDisplayWidget } from '../../solution-display-widget'; import TimelinePagination from './timeline-pagination'; @@ -25,6 +28,10 @@ const SolutionViewer = Loadable( () => import('../../SolutionViewer/SolutionViewer') ); +const mapDispatchToProps = { + openModal +}; + const { clientLocale } = envData as { clientLocale: keyof typeof langCodes }; const localeCode = langCodes[clientLocale]; @@ -33,46 +40,53 @@ const ITEMS_PER_PAGE = 15; interface TimelineProps { completedMap: CompletedChallenge[]; + openModal: (arg: string) => void; t: TFunction; username: string; } interface TimelineInnerProps extends TimelineProps { - idToNameMap: Map; + idToNameMap: Map; sortedTimeline: CompletedChallenge[]; totalPages: number; } +interface NameMap { + challengeTitle: string; + challengePath: string; +} + function TimelineInner({ + completedMap, idToNameMap, + openModal, sortedTimeline, totalPages, - completedMap, t, username }: TimelineInnerProps) { - const [solutionToView, setSolutionToView] = useState(null); + const [projectTitle, setProjectTitle] = useState(''); const [solutionOpen, setSolutionOpen] = useState(false); const [pageNo, setPageNo] = useState(1); - const [solution, setSolution] = useState(null); - const [challengeFiles, setChallengeFiles] = useState(null); + const [completedChallenge, setCompletedChallenge] = + useState(null); - function viewSolution( - id: string, - solution_: string | undefined | null, - challengeFiles_: ChallengeFiles - ): void { - setSolutionToView(id); + function viewSolution(completedChallenge: CompletedChallenge): void { + setCompletedChallenge(completedChallenge); setSolutionOpen(true); - setSolution(solution_ ?? ''); - setChallengeFiles(challengeFiles_); + } + + function viewProject(completedChallenge: CompletedChallenge): void { + setCompletedChallenge(completedChallenge); + setProjectTitle( + idToNameMap.get(completedChallenge.id)?.challengeTitle ?? '' + ); + openModal('projectPreview'); } function closeSolution(): void { - setSolutionToView(null); setSolutionOpen(false); - setSolution(null); - setChallengeFiles(null); + setCompletedChallenge(null); } function firstPage(): void { @@ -91,11 +105,11 @@ function TimelineInner({ function renderViewButton( completedChallenge: CompletedChallenge ): React.ReactNode { - const { id, solution, challengeFiles } = completedChallenge; return ( viewSolution(id, solution, challengeFiles)} + showUserCode={() => viewSolution(completedChallenge)} + showProjectPreview={() => viewProject(completedChallenge)} displayContext={'timeline'} > ); @@ -135,73 +149,90 @@ 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 endIndex = pageNo * ITEMS_PER_PAGE; return ( - -

{t('profile.timeline')}

- {completedMap.length === 0 ? ( -

- {t('profile.none-completed')}  - {t('profile.get-started')} -

- ) : ( - - - - - - - - - - {sortedTimeline.slice(startIndex, endIndex).map(renderCompletion)} - -
{t('profile.challenge')}{t('settings.labels.solution')}{t('profile.completed')}
- )} - {id && ( - - - - {`${username}'s Solution to ${ - // @ts-expect-error Need better TypeDef for this - idToNameMap.get(id).challengeTitle as string - }`} - - - - - - - - - - )} - {totalPages > 1 && ( - - )} -
+ <> + +

{t('profile.timeline')}

+ {completedMap.length === 0 ? ( +

+ {t('profile.none-completed')}  + {t('profile.get-started')} +

+ ) : ( + + + + + + + + + + {sortedTimeline.slice(startIndex, endIndex).map(renderCompletion)} + +
{t('profile.challenge')}{t('settings.labels.solution')}{t('profile.completed')}
+ )} + {id && ( + + + + {`${username}'s Solution to ${ + idToNameMap.get(id)?.challengeTitle ?? '' + }`} + + + + + + + + + + )} + {totalPages > 1 && ( + + )} +
+ + ); } /* 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 { +function useIdToNameMap(): Map { const { allChallengeNode: { edges } } = useStaticQuery(graphql` @@ -273,4 +304,4 @@ const Timeline = (props: TimelineProps): JSX.Element => { Timeline.displayName = 'Timeline'; -export default withTranslation()(Timeline); +export default connect(null, mapDispatchToProps)(withTranslation()(Timeline)); diff --git a/client/src/components/settings/certification.js b/client/src/components/settings/certification.js index 233137a5a5..60db066177 100644 --- a/client/src/components/settings/certification.js +++ b/client/src/components/settings/certification.js @@ -5,8 +5,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { withTranslation } from 'react-i18next'; import { createSelector } from 'reselect'; - 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 { projectMap, legacyProjectMap @@ -50,11 +54,16 @@ const propTypes = { isRelationalDatabaseCertV8: PropTypes.bool, isRespWebDesignCert: PropTypes.bool, isSciCompPyCertV7: PropTypes.bool, + openModal: PropTypes.func, t: PropTypes.func.isRequired, username: PropTypes.string, verifyCert: PropTypes.func.isRequired }; +const mapDispatchToProps = { + openModal +}; + const certifications = Object.keys(projectMap); const legacyCertifications = Object.keys(legacyProjectMap); const isCertSelector = ({ @@ -161,7 +170,7 @@ export class CertificationSettings extends Component { getUserIsCertMap = () => isCertMapSelector(this.props); getProjectSolution = (projectId, projectTitle) => { - const { completedChallenges } = this.props; + const { completedChallenges, openModal } = this.props; const completedProject = find( completedChallenges, ({ id }) => projectId === id @@ -171,8 +180,7 @@ export class CertificationSettings extends Component { } const { solution, challengeFiles } = completedProject; - - const onClickHandler = () => + const showUserCode = () => this.setState({ solutionViewer: { 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 ( ); @@ -361,11 +389,9 @@ export class CertificationSettings extends Component { }; render() { - const { - solutionViewer: { challengeFiles, solution, isOpen, projectTitle } - } = this.state; - + const { solutionViewer, projectViewer } = this.state; const { t } = this.props; + return (
@@ -378,16 +404,15 @@ export class CertificationSettings extends Component { {legacyCertifications.map(certName => this.renderCertifications(certName, legacyProjectMap) )} - {isOpen ? ( - - ) : null} + +
); @@ -397,4 +422,7 @@ export class CertificationSettings extends Component { CertificationSettings.displayName = 'CertificationSettings'; CertificationSettings.propTypes = propTypes; -export default withTranslation()(CertificationSettings); +export default connect( + null, + mapDispatchToProps +)(withTranslation()(CertificationSettings)); diff --git a/client/src/components/solution-display-widget/index.tsx b/client/src/components/solution-display-widget/index.tsx index 21745b9a87..07465518c8 100644 --- a/client/src/components/solution-display-widget/index.tsx +++ b/client/src/components/solution-display-widget/index.tsx @@ -11,22 +11,22 @@ import { getSolutionDisplayType } from '../../utils/solution-display-type'; interface Props { completedChallenge: CompletedChallenge; dataCy?: string; - showFilesSolution: () => void; + showUserCode: () => void; + showProjectPreview?: () => void; displayContext: 'timeline' | 'settings' | 'certification'; } export function SolutionDisplayWidget({ completedChallenge, dataCy, - showFilesSolution, + showUserCode, + showProjectPreview, displayContext }: Props) { const { id, solution, githubLink } = completedChallenge; const { t } = useTranslation(); - const dropdownTitle = - displayContext === 'settings' ? 'Show Solutions' : 'View'; - const projectLinkText = + const showOrViewText = displayContext === 'settings' ? t('buttons.show-solution') : t('buttons.view'); @@ -35,7 +35,7 @@ export function SolutionDisplayWidget({ @@ -64,18 +64,35 @@ export function SolutionDisplayWidget({ const MissingSolutionComponentForCertification = ( <>{t('certification.project.no-solution')} ); - const ShowFilesSolution = ( + const ShowUserCode = ( ); + const ShowMultifileProjectSolution = ( + + + {t('buttons.show-code')} + + + {t('buttons.show-project')} + + + ); + const ShowProjectAndGithubLinks = (
- {projectLinkText} + {showOrViewText} ); const MissingSolutionComponent = @@ -125,14 +142,16 @@ export function SolutionDisplayWidget({ const displayComponents = displayContext === 'certification' ? { - showFilesSolution: ShowFilesSolutionForCertification, - showProjectAndGitHubLinks: ShowProjectAndGithubLinkForCertification, + showUserCode: ShowFilesSolutionForCertification, + showMultifileProjectSolution: ShowMultifileProjectSolution, + showProjectAndGithubLinks: ShowProjectAndGithubLinkForCertification, showProjectLink: ShowProjectLinkForCertification, none: MissingSolutionComponentForCertification } : { - showFilesSolution: ShowFilesSolution, - showProjectAndGitHubLinks: ShowProjectAndGithubLinks, + showUserCode: ShowUserCode, + showMultifileProjectSolution: ShowMultifileProjectSolution, + showProjectAndGithubLinks: ShowProjectAndGithubLinks, showProjectLink: ShowProjectLink, none: MissingSolutionComponent }; diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index ca0a538ffa..3637a6c6bd 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -316,7 +316,9 @@ export type CompletedChallenge = { githubLink?: string; challengeType?: number; completedDate: number; - challengeFiles: ChallengeFiles; + challengeFiles: + | Pick[] + | null; }; export type Ext = 'js' | 'html' | 'css' | 'jsx'; diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 0dc6b783f5..1cafe765de 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -16,6 +16,7 @@ import { ChallengeFiles, ChallengeMeta, ChallengeNode, + CompletedChallenge, ResizeProps, Test } from '../../../redux/prop-types'; @@ -29,9 +30,7 @@ import HelpModal from '../components/help-modal'; import Notes from '../components/notes'; import Output from '../components/output'; import Preview from '../components/preview'; -import ProjectPreviewModal, { - PreviewConfig -} from '../components/project-preview-modal'; +import ProjectPreviewModal from '../components/project-preview-modal'; import SidePanel from '../components/side-panel'; import VideoModal from '../components/video-modal'; import { @@ -97,7 +96,10 @@ interface ShowClassicProps { output: string[]; pageContext: { challengeMeta: ChallengeMeta; - projectPreview: PreviewConfig & { showProjectPreview: boolean }; + projectPreview: { + challengeData: CompletedChallenge; + showProjectPreview: boolean; + }; }; t: TFunction; tests: Test[]; @@ -367,7 +369,6 @@ class ShowClassic extends Component { } } = this.props; const { description, title } = this.getChallenge(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return ( challengeFiles && ( { executeChallenge, pageContext: { challengeMeta: { nextChallengePath, prevChallengePath }, - projectPreview + projectPreview: { challengeData, showProjectPreview } }, challengeFiles, t @@ -494,7 +495,12 @@ class ShowClassic extends Component { - + ); diff --git a/client/src/templates/Challenges/components/project-preview-modal.tsx b/client/src/templates/Challenges/components/project-preview-modal.tsx index 81c0b0b5b1..7e80db9a6e 100644 --- a/client/src/templates/Challenges/components/project-preview-modal.tsx +++ b/client/src/templates/Challenges/components/project-preview-modal.tsx @@ -1,9 +1,8 @@ import { Button, Modal } from '@freecodecamp/react-bootstrap'; import React, { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; import { connect } from 'react-redux'; -import type { ChallengeFile, Required } from '../../../redux/prop-types'; +import type { CompletedChallenge } from '../../../redux/prop-types'; import { closeModal, setEditorFocusability, @@ -15,19 +14,20 @@ import Preview from './preview'; import './project-preview-modal.css'; -export interface PreviewConfig { - challengeType: boolean; - challengeFiles: ChallengeFile[]; - required: Required; - template: string; +interface ProjectPreviewMountedPayload { + challengeData: CompletedChallenge | null; + showProjectPreview: boolean; } interface Props { closeModal: (arg: string) => void; isOpen: boolean; - projectPreviewMounted: (previewConfig: PreviewConfig) => void; - previewConfig: PreviewConfig; + projectPreviewMounted: (payload: ProjectPreviewMountedPayload) => void; + challengeData: CompletedChallenge | null; setEditorFocusability: (focusability: boolean) => void; + showProjectPreview: boolean; + previewTitle: string; + closeText: string; } const mapStateToProps = (state: unknown) => ({ @@ -39,14 +39,16 @@ const mapDispatchToProps = { projectPreviewMounted }; -export function ProjectPreviewModal({ +function ProjectPreviewModal({ closeModal, isOpen, projectPreviewMounted, - previewConfig, - setEditorFocusability + challengeData, + setEditorFocusability, + showProjectPreview, + previewTitle, + closeText }: Props): JSX.Element { - const { t } = useTranslation(); useEffect(() => { if (isOpen) setEditorFocusability(false); }); @@ -66,17 +68,15 @@ export function ProjectPreviewModal({ className='project-preview-modal-header fcc-modal' closeButton={true} > - - {t('learn.project-preview-title')} - + {previewTitle} {/* remove type assertion once frame.js has been migrated to TS */} { - projectPreviewMounted(previewConfig); - }} + previewMounted={() => + projectPreviewMounted({ challengeData, showProjectPreview }) + } /> @@ -89,7 +89,7 @@ export function ProjectPreviewModal({ setEditorFocusability(true); }} > - {t('buttons.start-coding')} + {closeText} diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index 383e62a799..f5aa503c62 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -35,10 +35,18 @@ function postChallenge(update, username) { const saveChallenge = postUpdate$(update).pipe( retry(3), switchMap(({ points }) => { + // TODO: do this all in ajax.ts const payloadWithClientProperties = { - ...omit(update.payload, ['files']), - challengeFiles: update.payload.files ?? null + ...omit(update.payload, ['files']) }; + if (update.payload.files) { + payloadWithClientProperties.challengeFiles = update.payload.files.map( + ({ key, ...rest }) => ({ + ...rest, + fileKey: key + }) + ); + } return of( submitComplete({ username, diff --git a/client/src/utils/__fixtures/completed-challenges.ts b/client/src/utils/__fixtures/completed-challenges.ts index 07394bb495..8cb9f01ef4 100644 --- a/client/src/utils/__fixtures/completed-challenges.ts +++ b/client/src/utils/__fixtures/completed-challenges.ts @@ -27,6 +27,11 @@ export const withChallenges = { challengeFiles } +export const multifileSolution = { + ...withChallenges, + challengeType: 14 +} + export const onlyGithubLink = { ...baseChallenge, githubLink: 'https://some.thing' diff --git a/client/src/utils/solution-display-type.test.ts b/client/src/utils/solution-display-type.test.ts index 3842637fb3..69df0609f8 100644 --- a/client/src/utils/solution-display-type.test.ts +++ b/client/src/utils/solution-display-type.test.ts @@ -1,7 +1,8 @@ import { bothLinks, - legacySolution, invalidGithubLink, + legacySolution, + multifileSolution, onlyGithubLink, onlySolution, withChallenges @@ -13,10 +14,14 @@ describe('getSolutionDisplayType', () => { expect(getSolutionDisplayType(onlyGithubLink)).toBe('none'); }); it('should handle legacy solutions', () => { - expect(getSolutionDisplayType(legacySolution)).toBe('showFilesSolution'); + expect(getSolutionDisplayType(legacySolution)).toBe('showUserCode'); }); 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', () => { expect.assertions(2); @@ -24,6 +29,6 @@ describe('getSolutionDisplayType', () => { expect(getSolutionDisplayType(invalidGithubLink)).toBe('showProjectLink'); }); it('should handle solutions with both links', () => { - expect(getSolutionDisplayType(bothLinks)).toBe('showProjectAndGitHubLinks'); + expect(getSolutionDisplayType(bothLinks)).toBe('showProjectAndGithubLinks'); }); }); diff --git a/client/src/utils/solution-display-type.ts b/client/src/utils/solution-display-type.ts index 86712e67ef..ec7418181d 100644 --- a/client/src/utils/solution-display-type.ts +++ b/client/src/utils/solution-display-type.ts @@ -1,16 +1,21 @@ import type { CompletedChallenge } from '../redux/prop-types'; +import { challengeTypes } from '../../utils/challenge-types'; import { maybeUrlRE } from '.'; export const getSolutionDisplayType = ({ solution, githubLink, - challengeFiles + challengeFiles, + challengeType }: CompletedChallenge) => { - if (challengeFiles?.length) return 'showFilesSolution'; + if (challengeFiles?.length) + return challengeType === challengeTypes.multiFileCertProject + ? 'showMultifileProjectSolution' + : 'showUserCode'; 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'; + if (!maybeUrlRE.test(solution)) return 'showUserCode'; + if (maybeUrlRE.test(githubLink ?? '')) return 'showProjectAndGithubLinks'; return 'showProjectLink'; }; diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js index dc39d73ce4..d7a7620501 100644 --- a/client/utils/gatsby/challenge-page-creator.js +++ b/client/utils/gatsby/challenge-page-creator.js @@ -126,9 +126,7 @@ function getProjectPreviewConfig(challenge, allChallengeEdges) { challengeType !== challengeTypes.multiFileCertProject, challengeData: { challengeType: lastChallenge.challengeType, - challengeFiles: projectPreviewChallengeFiles, - required: lastChallenge.required, - template: lastChallenge.template + challengeFiles: projectPreviewChallengeFiles } }; } diff --git a/client/utils/test-utils.jsx b/client/utils/test-utils.jsx new file mode 100644 index 0000000000..81ff41ba9e --- /dev/null +++ b/client/utils/test-utils.jsx @@ -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 {children}; + } + 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 }; diff --git a/utils/polyvinyl.js b/utils/polyvinyl.js index b22f921ee9..f734b1130e 100644 --- a/utils/polyvinyl.js +++ b/utils/polyvinyl.js @@ -97,6 +97,19 @@ function setImportedFiles(importedFiles, poly) { 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 function clearHeadTail(poly) { checkPoly(poly); @@ -151,6 +164,7 @@ module.exports = { setExt, setImportedFiles, compileHeadTail, + regeneratePathAndHistory, transformContents, transformHeadTailAndContents };