diff --git a/client/src/client-only-routes/show-project-links.tsx b/client/src/client-only-routes/show-project-links.tsx index 20e3a2042cc..3e7a895e4ca 100644 --- a/client/src/client-only-routes/show-project-links.tsx +++ b/client/src/client-only-routes/show-project-links.tsx @@ -6,7 +6,11 @@ import { Table, Spacer } from '@freecodecamp/ui'; import { Link } from '../components/helpers'; import ProjectModal from '../components/SolutionViewer/project-modal'; -import type { CompletedChallenge, User } from '../redux/prop-types'; +import type { + ChallengeData, + CompletedChallenge, + User +} from '../redux/prop-types'; import { certsToProjects } from '../../config/cert-and-project-map'; import { SolutionDisplayWidget } from '../components/solution-display-widget'; @@ -15,7 +19,7 @@ import ExamResultsModal from '../components/SolutionViewer/exam-results-modal'; import { openModal } from '../templates/Challenges/redux/actions'; -import { regeneratePathAndHistory } from '../../../shared/utils/polyvinyl'; +import { regenerateMissingProperties } from '../../../shared/utils/polyvinyl'; import '../components/layouts/project-links.css'; import { Certification } from '../../../shared/config/certification-settings'; interface ShowProjectLinksProps { @@ -164,13 +168,14 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => { } }; - const challengeData: CompletedChallenge | null = completedChallenge + const challengeData: ChallengeData | null = completedChallenge ? { ...completedChallenge, // // eslint-disable-next-line @typescript-eslint/no-unsafe-argument challengeFiles: - completedChallenge?.challengeFiles?.map(regeneratePathAndHistory) ?? - null + completedChallenge?.challengeFiles?.map( + regenerateMissingProperties + ) ?? null } : null; diff --git a/client/src/components/profile/components/time-line.tsx b/client/src/components/profile/components/time-line.tsx index ef656efcb53..b634d325684 100644 --- a/client/src/components/profile/components/time-line.tsx +++ b/client/src/components/profile/components/time-line.tsx @@ -10,9 +10,12 @@ import { Table, Button, Modal, Spacer } from '@freecodecamp/ui'; import envData from '../../../../config/env.json'; import { getLangCode } from '../../../../../shared/config/i18n'; import { getCertIds, getPathFromID } from '../../../../utils'; -import { regeneratePathAndHistory } from '../../../../../shared/utils/polyvinyl'; +import { regenerateMissingProperties } from '../../../../../shared/utils/polyvinyl'; import CertificationIcon from '../../../assets/icons/certification'; -import { CompletedChallenge } from '../../../redux/prop-types'; +import type { + ChallengeData, + CompletedChallenge +} from '../../../redux/prop-types'; import ProjectPreviewModal from '../../../templates/Challenges/components/project-preview-modal'; import ExamResultsModal from '../../SolutionViewer/exam-results-modal'; import { openModal } from '../../../templates/Challenges/redux/actions'; @@ -160,13 +163,14 @@ function TimelineInner({ ); } - const challengeData: CompletedChallenge | null = completedChallenge + const challengeData: ChallengeData | null = completedChallenge ? { ...completedChallenge, // // eslint-disable-next-line @typescript-eslint/no-unsafe-argument challengeFiles: - completedChallenge?.challengeFiles?.map(regeneratePathAndHistory) ?? - null + completedChallenge?.challengeFiles?.map( + regenerateMissingProperties + ) ?? null } : null; diff --git a/client/src/components/settings/certification.tsx b/client/src/components/settings/certification.tsx index df3aa82353f..ce2ba16b201 100644 --- a/client/src/components/settings/certification.tsx +++ b/client/src/components/settings/certification.tsx @@ -6,7 +6,7 @@ import ScrollableAnchor, { configureAnchors } from 'react-scrollable-anchor'; import { connect } from 'react-redux'; import { Table, Button, Spacer } from '@freecodecamp/ui'; -import { regeneratePathAndHistory } from '../../../../shared/utils/polyvinyl'; +import { regenerateMissingProperties } from '../../../../shared/utils/polyvinyl'; import ProjectPreviewModal from '../../templates/Challenges/components/project-preview-modal'; import ExamResultsModal from '../SolutionViewer/exam-results-modal'; import { openModal } from '../../templates/Challenges/redux/actions'; @@ -24,7 +24,8 @@ import { } from '../../../../shared/config/certification-settings'; import env from '../../../config/env.json'; -import { +import type { + ChallengeData, ClaimedCertifications, CompletedChallenge, GeneratedExamResults, @@ -206,7 +207,7 @@ function CertificationSettings(props: CertificationSettingsProps) { const [challengeFiles, setChallengeFiles] = useState< CompletedChallenge['challengeFiles'] | null >(null); - const [challengeData, setChallengeData] = useState( + const [challengeData, setChallengeData] = useState( null ); const [solution, setSolution] = useState(); @@ -245,8 +246,9 @@ function CertificationSettings(props: CertificationSettingsProps) { ? { ...completedProject, challengeFiles: - completedProject?.challengeFiles?.map(regeneratePathAndHistory) ?? - null + completedProject?.challengeFiles?.map( + regenerateMissingProperties + ) ?? null } : null; diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 31a22f3dadd..be0db623874 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -366,7 +366,7 @@ export type SavedChallengeFile = { export type SavedChallengeFiles = SavedChallengeFile[]; -export type CompletedChallenge = { +export interface CompletedChallenge { id: string; solution?: string | null; githubLink?: string; @@ -376,7 +376,11 @@ export type CompletedChallenge = { | Pick[] | null; examResults?: GeneratedExamResults; -}; +} + +export interface ChallengeData extends CompletedChallenge { + challengeFiles: ChallengeFile[] | null; +} export type FileKey = | 'scriptjs' diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index fcdf572616e..c51e506f30a 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -15,11 +15,11 @@ import { challengeTypes } from '../../../../../shared/config/challenge-types'; import LearnLayout from '../../../components/layouts/learn'; import { MAX_MOBILE_WIDTH } from '../../../../config/misc'; -import { +import type { + ChallengeData, ChallengeFiles, ChallengeMeta, ChallengeNode, - CompletedChallenge, ResizeProps, SavedChallenge, SavedChallengeFiles, @@ -113,7 +113,7 @@ interface ShowClassicProps extends Pick { pageContext: { challengeMeta: ChallengeMeta; projectPreview: { - challengeData: CompletedChallenge; + challengeData: ChallengeData; }; }; updateChallengeMeta: (arg0: ChallengeMeta) => void; diff --git a/client/src/templates/Challenges/components/project-preview-modal.tsx b/client/src/templates/Challenges/components/project-preview-modal.tsx index 3673a94023b..c8c67669353 100644 --- a/client/src/templates/Challenges/components/project-preview-modal.tsx +++ b/client/src/templates/Challenges/components/project-preview-modal.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { Button, Modal } from '@freecodecamp/ui'; -import type { CompletedChallenge } from '../../../redux/prop-types'; +import type { ChallengeData } from '../../../redux/prop-types'; import { closeModal, setEditorFocusability, @@ -15,14 +15,14 @@ import Preview from './preview'; import './project-preview-modal.css'; interface ProjectPreviewMountedPayload { - challengeData: CompletedChallenge | null; + challengeData: ChallengeData | null; } interface Props { closeModal: (arg: string) => void; isOpen: boolean; projectPreviewMounted: (payload: ProjectPreviewMountedPayload) => void; - challengeData: CompletedChallenge | null; + challengeData: ChallengeData | null; setEditorFocusability: (focusability: boolean) => void; previewTitle: string; closeText: string; diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index a535cde5962..9514a4a3290 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -354,13 +354,18 @@ function* previewProjectSolutionSaga({ payload }) { try { if (canBuildChallenge(challengeData)) { const buildData = yield buildChallengeData(challengeData); + if (buildData.error) throw Error(buildData.error); + if (challengeHasPreview(challengeData)) { const document = yield getContext('document'); yield call(updateProjectPreview, buildData, document); + } else { + throw Error('Project does not have a preview'); } } } catch (err) { - console.log(err); + console.error('Unable to show project preview'); + console.error(err); } } diff --git a/e2e/completed-project-preview.spec.ts b/e2e/completed-project-preview.spec.ts new file mode 100644 index 00000000000..1907dbffd95 --- /dev/null +++ b/e2e/completed-project-preview.spec.ts @@ -0,0 +1,86 @@ +import { execSync } from 'node:child_process'; + +import { test, expect, Page } from '@playwright/test'; + +import tributePage from './fixtures/tribute-page.json'; + +import { authedRequest } from './utils/request'; + +const unlockedProfile = { + isLocked: false, + showAbout: true, + showCerts: true, + showDonation: true, + showHeatMap: true, + showLocation: true, + showName: true, + showPoints: true, + showPortfolio: true, + showTimeLine: true +}; + +async function expectPreviewToBeShown(page: Page) { + await page + .getByRole('button', { name: 'View Solution for Build a Tribute Page' }) + .first() + .click(); + await page.getByRole('menuitem', { name: 'View Project' }).click(); + const modalHeading = page.getByRole('heading', { + name: 'Build a Tribute Page', + exact: true + }); + await expect(modalHeading).toBeVisible(); + + const projectPreview = page.frameLocator('#fcc-project-preview-frame'); + await expect(projectPreview.getByText('Tribute page text')).toBeVisible(); +} + +test.describe('Completed project preview', () => { + test.use({ storageState: 'playwright/.auth/development-user.json' }); + + test.beforeEach(async ({ request }) => { + execSync('node ./tools/scripts/seed/seed-demo-user'); + + await authedRequest({ + request, + method: 'post', + endpoint: '/modern-challenge-completed', + data: { + id: tributePage.id, + challengeType: 14, + files: [tributePage.htmlFile, tributePage.cssFile] + } + }); + + await authedRequest({ + request, + endpoint: '/update-my-profileui', + method: 'put', + data: { + profileUI: unlockedProfile + } + }); + }); + + test('it should be viewable on the timeline', async ({ page }) => { + await page.goto('/developmentuser'); + + if (!process.env.CI) { + await page + .getByRole('button', { name: 'Preview custom 404 page' }) + .click(); + } + + await expect( + page.getByRole('heading', { name: '@developmentuser' }) + ).toBeVisible(); + + await expectPreviewToBeShown(page); + }); + + test('it should be viewable on the settings page', async ({ page }) => { + await page.goto('/settings'); + + await expectPreviewToBeShown(page); + }); +}); diff --git a/e2e/completion-modal.spec.ts b/e2e/completion-modal.spec.ts index 063639d87d0..d4398949838 100644 --- a/e2e/completion-modal.spec.ts +++ b/e2e/completion-modal.spec.ts @@ -1,3 +1,5 @@ +import { execSync } from 'node:child_process'; + import { test, expect } from '@playwright/test'; import translations from '../client/i18n/locales/english/translations.json'; import { authedRequest } from './utils/request'; @@ -6,6 +8,10 @@ import { allowTrailingSlash } from './utils/url'; const nextChallengeURL = '/learn/data-analysis-with-python/data-analysis-with-python-projects/demographic-data-analyzer'; +test.beforeAll(() => { + execSync('node ./tools/scripts/seed/seed-demo-user --certified-user'); +}); + test.beforeEach(async ({ page }) => { await page.goto( '/learn/data-analysis-with-python/data-analysis-with-python-projects/mean-variance-standard-deviation-calculator' diff --git a/e2e/fixtures/tribute-page-css.json b/e2e/fixtures/tribute-page-css.json deleted file mode 100644 index 0723574f5d8..00000000000 --- a/e2e/fixtures/tribute-page-css.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tribute-page-css": { - "contents": "#image{max-width:100%;height:auto;display:block;margin:0 auto;}" - } -} diff --git a/e2e/fixtures/tribute-page-html.json b/e2e/fixtures/tribute-page-html.json deleted file mode 100644 index 8f645d4a546..00000000000 --- a/e2e/fixtures/tribute-page-html.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tribute-page-html": { - "contents": "
D
\"D\"
s
s
L
" - } -} diff --git a/e2e/fixtures/tribute-page.json b/e2e/fixtures/tribute-page.json new file mode 100644 index 00000000000..684516ccc51 --- /dev/null +++ b/e2e/fixtures/tribute-page.json @@ -0,0 +1,17 @@ +{ + "id": "bd7158d8c442eddfaeb5bd18", + "htmlFile": { + "contents": "
D
\"D\"
s
s
L Tribute page text
", + "key": "indexhtml", + "ext": "html", + "name": "index", + "history": ["index.html"] + }, + "cssFile": { + "contents": "#image{max-width:100%;height:auto;display:block;margin:0 auto;}", + "key": "stylescss", + "ext": "css", + "name": "styles", + "history": ["styles.css"] + } +} diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 5c7173d994b..06d894ba88d 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -2,8 +2,7 @@ import { execSync } from 'child_process'; import { test, expect, Page } from '@playwright/test'; import { SuperBlocks } from '../shared/config/curriculum'; import translations from '../client/i18n/locales/english/translations.json'; -import tributePageHtml from './fixtures/tribute-page-html.json'; -import tributePageCss from './fixtures/tribute-page-css.json'; +import tributePage from './fixtures/tribute-page.json'; import curriculum from './fixtures/js-ads-projects.json'; import { authedRequest } from './utils/request'; @@ -255,8 +254,8 @@ test.describe('Completion modal should be shown after submitting a project', () await context.grantPermissions(['clipboard-read', 'clipboard-write']); const tributeContent = [ - tributePageHtml['tribute-page-html'].contents, - tributePageCss['tribute-page-css'].contents + tributePage.htmlFile.contents, + tributePage.cssFile.contents ]; await page.goto( diff --git a/shared/utils/polyvinyl.ts b/shared/utils/polyvinyl.ts index 4364bb77b03..c29bca04a78 100644 --- a/shared/utils/polyvinyl.ts +++ b/shared/utils/polyvinyl.ts @@ -4,14 +4,16 @@ import invariant from 'invariant'; const exts = ['js', 'html', 'css', 'jsx', 'ts', 'py'] as const; export type Ext = (typeof exts)[number]; -export type IncompleteChallengeFile = { +export interface IncompleteChallengeFile { fileKey: string; ext: Ext; name: string; contents: string; -}; + head?: string; + tail?: string; +} -export type ChallengeFile = IncompleteChallengeFile & { +export interface ChallengeFile extends IncompleteChallengeFile { editableRegionBoundaries?: number[]; editableContents?: string; usesMultifileEditor?: boolean; @@ -22,7 +24,7 @@ export type ChallengeFile = IncompleteChallengeFile & { source?: string | null; path: string; history: string[]; -}; +} type PolyProps = { name: string; @@ -118,12 +120,14 @@ export function setContent( // This is currently only used to add back properties that are not stored in the // database. -export function regeneratePathAndHistory(file: IncompleteChallengeFile) { +export function regenerateMissingProperties(file: IncompleteChallengeFile) { const newPath = file.name + '.' + file.ext; const newFile = { ...file, path: newPath, - history: [newPath] + history: [newPath], + head: file.head ?? '', + tail: file.tail ?? '' }; return newFile; }