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')}
-
- ) : (
-
-
-
- {t('profile.challenge')} |
- {t('settings.labels.solution')} |
- {t('profile.completed')} |
-
-
-
- {sortedTimeline.slice(startIndex, endIndex).map(renderCompletion)}
-
-
- )}
- {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')}
+
+ ) : (
+
+
+
+ {t('profile.challenge')} |
+ {t('settings.labels.solution')} |
+ {t('profile.completed')} |
+
+
+
+ {sortedTimeline.slice(startIndex, endIndex).map(renderCompletion)}
+
+
+ )}
+ {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 = (
+
+
+
+
+ );
+
const ShowProjectAndGithubLinks = (