diff --git a/client/gatsby-node.js b/client/gatsby-node.js index f139d2a827b..09ff2c7574b 100644 --- a/client/gatsby-node.js +++ b/client/gatsby-node.js @@ -163,3 +163,15 @@ exports.onCreateBabelConfig = ({ actions }) => { name: '@babel/plugin-proposal-export-default-from' }); }; + +exports.createSchemaCustomization = ({ actions }) => { + const { createTypes } = actions; + // This hook is supported by the test runner, but is not currently used by the + // client, so we have to tell Gatsby that it exists. + const typeDefs = ` + type ChallengeNodeChallengeHooks { + afterEach: String + } + `; + createTypes(typeDefs); +}; diff --git a/client/schema.gql b/client/schema.gql index bdda699158a..d98f85567a4 100644 --- a/client/schema.gql +++ b/client/schema.gql @@ -1,4 +1,173 @@ -### Type definitions saved at 2026-01-20T15:53:20.990Z ### +### Type definitions saved at 2026-02-12T12:28:08.262Z ### + +enum RemoteFileFit { + COVER + FILL + OUTSIDE + CONTAIN +} + +enum RemoteFileFormat { + AUTO + JPG + PNG + WEBP + AVIF +} + +enum RemoteFileLayout { + FIXED + FULL_WIDTH + CONSTRAINED +} + +enum RemoteFilePlaceholder { + DOMINANT_COLOR + BLURRED + TRACED_SVG + NONE +} + +enum RemoteFileCropFocus { + CENTER + TOP + RIGHT + BOTTOM + LEFT + ENTROPY + EDGES + FACES +} + +type RemoteFileResize { + width: Int + height: Int + src: String +} + +""" +Remote Interface +""" +interface RemoteFile { + id: ID! + mimeType: String! + filename: String! + filesize: Int + width: Int + height: Int + publicUrl: String! + resize( + width: Int + height: Int + aspectRatio: Float + fit: RemoteFileFit = COVER + + """ + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], and you should rarely need to + change this. Take care if you specify JPG or PNG when you do + not know the formats of the source images, as this could lead to unwanted + results such as converting JPEGs to PNGs. Specifying + both PNG and JPG is not supported and will be ignored. + """ + format: RemoteFileFormat = AUTO + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): RemoteFileResize + + """ + Data used in the component. See https://gatsby.dev/img for more info. + """ + gatsbyImage( + """ + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: The image resizes to fit its container. Pass a "sizes" option if + it isn't going to be the full width of the screen. + CONSTRAINED: Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size. + """ + layout: RemoteFileLayout = CONSTRAINED + + """ + The display width of the generated image for layout = FIXED, and the display + width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + """ + width: Int + + """ + If set, the height of the generated image. If omitted, it is calculated from + the supplied width, matching the aspect ratio of the source image. + """ + height: Int + + """ + Format of generated placeholder image, displayed while the main image loads. + BLURRED: a blurred, low resolution image, encoded as a base64 data URI + DOMINANT_COLOR: a solid color, calculated from the dominant color of the image (default). + TRACED_SVG: deprecated. Will use DOMINANT_COLOR. + NONE: no placeholder. Set the argument "backgroundColor" to use a fixed background color. + """ + placeholder: RemoteFilePlaceholder = DOMINANT_COLOR + + """ + If set along with width or height, this will set the value of the other + dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + """ + aspectRatio: Float + + """ + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], and you should rarely need to + change this. Take care if you specify JPG or PNG when you do + not know the formats of the source images, as this could lead to unwanted + results such as converting JPEGs to PNGs. Specifying + both PNG and JPG is not supported and will be ignored. + """ + formats: [RemoteFileFormat!] = [AUTO, WEBP, AVIF] + + """ + A list of image pixel densities to generate for FIXED and CONSTRAINED + images. You should rarely need to change this. It will never generate images + larger than the source, and will always include a 1x image. + Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, and [0.25, 0.5, 1, 2] + for fluid. In this case, an image with a fluid layout and width = 400 would + generate images at 100, 200, 400 and 800px wide. + """ + outputPixelDensities: [Float] = [0.25, 0.5, 1, 2] + + """ + Specifies the image widths to generate. You should rarely need to change + this. For FIXED and CONSTRAINED images it is better to allow these to be + determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override + the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + """ + breakpoints: [Int] = [750, 1080, 1366, 1920] + + """ + The "sizes" property, passed to the img tag. This describes the display size of the image. + This does not affect the generated images, but is used by the browser to + decide which images to download. You can leave this blank for fixed images, + or if the responsive image + container will be the full width of the screen. In these cases we will generate an appropriate value. + """ + sizes: String + + """ + Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio. + """ + backgroundColor: String + fit: RemoteFileFit = COVER + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): GatsbyImageData +} type File implements Node @dontInfer { sourceInstanceName: String! @@ -68,18 +237,19 @@ type Directory implements Node @dontInfer { ctime: Date! @dateformat birthtime: Date @deprecated(reason: "Use `birthTime` instead") birthtimeMs: Float @deprecated(reason: "Use `birthTime` instead") - blksize: Int - blocks: Int } -type Site implements Node @dontInfer { +type Site implements Node @derivedTypes @dontInfer { buildTime: Date @dateformat siteMetadata: SiteSiteMetadata port: Int host: String flags: SiteFlags + trailingSlash: String pathPrefix: String polyfill: Boolean + jsxRuntime: String + graphqlTypegen: Boolean } type SiteSiteMetadata { @@ -108,6 +278,8 @@ type SitePage implements Node @dontInfer { internalComponentName: String! componentChunkName: String! matchPath: String + pageContext: JSON @proxy(from: "context", fromNode: false) + pluginCreator: SitePlugin @link(by: "id", from: "pluginCreatorId") } type SitePlugin implements Node @dontInfer { @@ -118,55 +290,8 @@ type SitePlugin implements Node @dontInfer { browserAPIs: [String] ssrAPIs: [String] pluginFilepath: String - pluginOptions: SitePluginPluginOptions - packageJson: SitePluginPackageJson -} - -type SitePluginPluginOptions { - analyzerMode: String - postcssOptions: SitePluginPluginOptionsPostcssOptions - prefixes: [String] - name: String - curriculumPath: String - path: String - jsFrontmatterEngine: Boolean - identity: String - pathCheck: Boolean - allExtensions: Boolean - isTSX: Boolean - jsxPragma: String -} - -type SitePluginPluginOptionsPostcssOptions { - config: String -} - -type SitePluginPackageJson { - name: String - description: String - version: String - main: String - author: String - license: String - dependencies: [SitePluginPackageJsonDependencies] - devDependencies: [SitePluginPackageJsonDevDependencies] - peerDependencies: [SitePluginPackageJsonPeerDependencies] - keywords: [String] -} - -type SitePluginPackageJsonDependencies { - name: String - version: String -} - -type SitePluginPackageJsonDevDependencies { - name: String - version: String -} - -type SitePluginPackageJsonPeerDependencies { - name: String - version: String + pluginOptions: JSON + packageJson: JSON } type SiteBuildMetadata implements Node @dontInfer { @@ -202,6 +327,7 @@ type MarkdownWordCount { type MarkdownRemark implements Node @childOf(mimeTypes: ["text/markdown", "text/x-markdown"], types: ["File"]) + @derivedTypes @dontInfer { frontmatter: MarkdownRemarkFrontmatter excerpt: String @@ -214,244 +340,26 @@ type MarkdownRemarkFrontmatter { title: String superBlock: String certification: String - block: String } type MarkdownRemarkFields { - nodeIdentity: String slug: String } -type ChallengeNode implements Node @dontInfer { - challenge: Challenge - sourceInstanceName: String -} - -type Challenge { - assignments: [String] - bilibiliIds: BilibiliIds - block: String - blockId: String - blockLayout: String - blockLabel: String - certification: String - challengeFiles: [FileContents] - challengeOrder: Int - challengeType: Int - chapter: String - dashedName: String - demoType: String - description: String - disableLoopProtectPreview: Boolean - disableLoopProtectTests: Boolean - explanation: String - fillInTheBlank: FillInTheBlank - forumTopicId: Int - hasEditableBoundaries: Boolean - helpCategory: String - hooks: Hooks - id: String - instructions: String - isLastChallengeInBlock: Boolean - isPrivate: Boolean - lang: String - module: String - msTrophyId: String - nodules: [Nodule] - notes: String - order: Int - prerequisites: [PrerequisiteChallenge] - questions: [Question] - quizzes: [Quiz] - required: [RequiredResource] - saveSubmissionToDB: Boolean - scene: Scene - solutions: [[FileContents]] - suborder: Int - superBlock: String - superOrder: Int - template: String - tests: [Test] - fields: ChallengeFields - title: String - transcript: String - translationPending: Boolean - url: String - usesMultifileEditor: Boolean - videoId: String - videoLocaleIds: VideoLocaleIds - videoUrl: String - isExam: Boolean - showSpeakingButton: Boolean - inputType: String -} - -type BilibiliIds { - aid: Int - bvid: String - cid: Int -} - -type FileContents { - fileKey: String - ext: String - name: String - contents: String - head: String - tail: String - editableRegionBoundaries: [Int] - path: String - error: String - seed: String - id: String - history: [String] -} - -type FillInTheBlank { - sentence: String - blanks: [Blank] - inputType: String -} - -type Blank { - answer: String - feedback: String -} - -type Hooks { +type ChallengeNodeChallengeHooks { + afterEach: String beforeAll: String beforeEach: String afterAll: String - afterEach: String } -type Nodule { - type: String - data: JSON -} - -type PrerequisiteChallenge { - id: String - title: String -} - -type Question { - text: String - answers: [Answer] - solution: Int -} - -type Answer { - answer: String - feedback: String - audioId: String -} - -type Quiz { - questions: [QuizQuestion] -} - -type QuizAudio { - filename: String - startTimestamp: Float - finishTimestamp: Float -} - -type QuizTranscriptLine { - character: String - text: String -} - -type QuizAudioData { - audio: QuizAudio - transcript: [QuizTranscriptLine] -} - -type QuizQuestion { - text: String - distractors: [String] - answer: String - audioData: QuizAudioData -} - -type RequiredResource { - link: String - raw: Boolean - src: String - crossDomain: Boolean -} - -type Scene { - setup: SceneSetup - commands: [SceneCommands] -} - -type SceneSetup { - background: String - characters: [SetupCharacter] - audio: SetupAudio - alwaysShowDialogue: Boolean -} - -type SetupCharacter { - character: String - position: CharacterPosition - opacity: Float -} - -type CharacterPosition { - x: Float - y: Float - z: Float -} - -type SetupAudio { - filename: String - startTime: Float - startTimestamp: Float - finishTimestamp: Float -} - -type SceneCommands { - background: String - character: String - position: CharacterPosition - opacity: Float - startTime: Float - finishTime: Float - dialogue: Dialogue -} - -type Dialogue { - text: String - align: String -} - -type Test { - id: String - text: String - testString: String - title: String -} - -type ChallengeFields { - slug: String - blockHashSlug: String -} - -type VideoLocaleIds { - espanol: String - italian: String - portuguese: String -} - -type SuperBlockStructure implements Node @dontInfer { - blocks: [String] - superBlock: String +type SuperBlockStructure implements Node @derivedTypes @dontInfer { chapters: [SuperBlockStructureChapters] + superBlock: String + blocks: [String] } -type SuperBlockStructureChapters { +type SuperBlockStructureChapters @derivedTypes { dashedName: String modules: [SuperBlockStructureChaptersModules] chapterType: String @@ -465,12 +373,249 @@ type SuperBlockStructureChaptersModules { comingSoon: Boolean } -type CertificateNode implements Node @dontInfer { +type ChallengeNode implements Node @derivedTypes @dontInfer { + sourceInstanceName: String + challenge: ChallengeNodeChallenge +} + +type ChallengeNodeChallenge @derivedTypes { + id: String + title: String + challengeType: Int + dashedName: String + demoType: String + challengeFiles: [ChallengeNodeChallengeChallengeFiles] + solutions: [[ChallengeNodeChallengeSolutions]] + assignments: [String] + tests: [ChallengeNodeChallengeTests] + description: String + translationPending: Boolean + sourceLocation: String + block: String + blockLabel: String + blockLayout: String + hasEditableBoundaries: Boolean + order: Int + instructions: String + questions: [ChallengeNodeChallengeQuestions] + superBlock: String + superOrder: Int + challengeOrder: Int + isLastChallengeInBlock: Boolean + required: [ChallengeNodeChallengeRequired] + helpCategory: String + usesMultifileEditor: Boolean + disableLoopProtectTests: Boolean + disableLoopProtectPreview: Boolean + certification: String + fields: ChallengeNodeChallengeFields + quizzes: [ChallengeNodeChallengeQuizzes] + chapter: String + module: String + hooks: ChallengeNodeChallengeHooks + nodules: [ChallengeNodeChallengeNodules] + forumTopicId: Int + videoId: String + bilibiliIds: ChallengeNodeChallengeBilibiliIds + saveSubmissionToDB: Boolean + lang: String + scene: ChallengeNodeChallengeScene + explanation: String + fillInTheBlank: ChallengeNodeChallengeFillInTheBlank + inputType: String + videoUrl: String + url: String + template: String + transcript: String + isExam: Boolean + showSpeakingButton: Boolean + videoLocaleIds: ChallengeNodeChallengeVideoLocaleIds + notes: String + prerequisites: [ChallengeNodeChallengePrerequisites] + msTrophyId: String +} + +type ChallengeNodeChallengeChallengeFiles { + head: String + tail: String + id: String + editableRegionBoundaries: [Int] + history: [String] + name: String + ext: String + path: String + fileKey: String + contents: String + seed: String +} + +type ChallengeNodeChallengeSolutions { + head: String + tail: String + id: String + history: [String] + name: String + ext: String + path: String + fileKey: String + contents: String + seed: String +} + +type ChallengeNodeChallengeTests { + text: String + testString: String +} + +type ChallengeNodeChallengeQuestions @derivedTypes { + text: String + answers: [ChallengeNodeChallengeQuestionsAnswers] + solution: Int +} + +type ChallengeNodeChallengeQuestionsAnswers { + answer: String + feedback: String + audioId: String +} + +type ChallengeNodeChallengeRequired { + src: String + link: String + raw: Boolean +} + +type ChallengeNodeChallengeFields { + slug: String + blockHashSlug: String +} + +type ChallengeNodeChallengeQuizzes @derivedTypes { + questions: [ChallengeNodeChallengeQuizzesQuestions] +} + +type ChallengeNodeChallengeQuizzesQuestions @derivedTypes { + text: String + distractors: [String] + answer: String + audioData: ChallengeNodeChallengeQuizzesQuestionsAudioData +} + +type ChallengeNodeChallengeQuizzesQuestionsAudioData @derivedTypes { + audio: ChallengeNodeChallengeQuizzesQuestionsAudioDataAudio + transcript: [ChallengeNodeChallengeQuizzesQuestionsAudioDataTranscript] +} + +type ChallengeNodeChallengeQuizzesQuestionsAudioDataAudio { + filename: String + startTimestamp: Int + finishTimestamp: Float +} + +type ChallengeNodeChallengeQuizzesQuestionsAudioDataTranscript { + character: String + text: String +} + +type ChallengeNodeChallengeNodules @derivedTypes { + type: String + contents: String + files: [ChallengeNodeChallengeNodulesFiles] +} + +type ChallengeNodeChallengeNodulesFiles { + contents: String + ext: String + name: String + contentsHtml: String +} + +type ChallengeNodeChallengeBilibiliIds { + aid: Int + bvid: String + cid: Int +} + +type ChallengeNodeChallengeScene @derivedTypes { + setup: ChallengeNodeChallengeSceneSetup + commands: [ChallengeNodeChallengeSceneCommands] +} + +type ChallengeNodeChallengeSceneSetup @derivedTypes { + background: String + characters: [ChallengeNodeChallengeSceneSetupCharacters] + audio: ChallengeNodeChallengeSceneSetupAudio + alwaysShowDialogue: Boolean +} + +type ChallengeNodeChallengeSceneSetupCharacters @derivedTypes { + character: String + position: ChallengeNodeChallengeSceneSetupCharactersPosition + opacity: Int +} + +type ChallengeNodeChallengeSceneSetupCharactersPosition { + x: Int + y: Int + z: Float +} + +type ChallengeNodeChallengeSceneSetupAudio { + filename: String + startTime: Float + startTimestamp: Float + finishTimestamp: Float +} + +type ChallengeNodeChallengeSceneCommands @derivedTypes { + character: String + opacity: Int + startTime: Float + finishTime: Float + dialogue: ChallengeNodeChallengeSceneCommandsDialogue + position: ChallengeNodeChallengeSceneCommandsPosition + background: String +} + +type ChallengeNodeChallengeSceneCommandsDialogue { + text: String + align: String +} + +type ChallengeNodeChallengeSceneCommandsPosition { + x: Int + y: Int + z: Float +} + +type ChallengeNodeChallengeFillInTheBlank @derivedTypes { + sentence: String + blanks: [ChallengeNodeChallengeFillInTheBlankBlanks] + inputType: String +} + +type ChallengeNodeChallengeFillInTheBlankBlanks { + answer: String + feedback: String +} + +type ChallengeNodeChallengeVideoLocaleIds { + espanol: String + italian: String + portuguese: String +} + +type ChallengeNodeChallengePrerequisites { + id: String + title: String +} + +type CertificateNode implements Node @derivedTypes @dontInfer { sourceInstanceName: String challenge: CertificateNodeChallenge } -type CertificateNodeChallenge { +type CertificateNodeChallenge @derivedTypes { id: String title: String certification: String diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index d4562084f86..a7848c6b4e2 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -193,12 +193,12 @@ type Nodule = ParagraphNodule | InteractiveEditorNodule; type ParagraphNodule = { type: 'paragraph'; - data: string; + contents: string; }; type InteractiveEditorNodule = { type: 'interactiveEditor'; - data: { + files: { ext: Ext; name: string; contents: string; diff --git a/client/src/templates/Challenges/components/interactive-editor.tsx b/client/src/templates/Challenges/components/interactive-editor.tsx index bb322d9239c..64ef19041ae 100644 --- a/client/src/templates/Challenges/components/interactive-editor.tsx +++ b/client/src/templates/Challenges/components/interactive-editor.tsx @@ -102,8 +102,12 @@ const InteractiveEditor = ({ files }: Props) => { {layout === 'preview' ? ( showConsole ? ( <> - + { ) ) : ( - + )} diff --git a/client/src/templates/Challenges/generic/show.tsx b/client/src/templates/Challenges/generic/show.tsx index 25dbf6014b8..7ceeca65956 100644 --- a/client/src/templates/Challenges/generic/show.tsx +++ b/client/src/templates/Challenges/generic/show.tsx @@ -77,12 +77,12 @@ function renderNodule( ) { switch (nodule.type) { case 'paragraph': - return ; + return ; case 'interactiveEditor': if (showInteractiveEditor) { - return ; + return ; } else { - const files = nodule.data; + const { files } = nodule; return files.map((file, index) => ( )); @@ -390,7 +390,13 @@ export const query = graphql` description nodules { type - data + contents + files { + ext + name + contents + contentsHtml + } } explanation helpCategory diff --git a/curriculum/schema/__snapshots__/challenge-schema.test.mjs.snap b/curriculum/schema/__snapshots__/challenge-schema.test.mjs.snap index 51db29243ff..633a1b6e4ef 100644 --- a/curriculum/schema/__snapshots__/challenge-schema.test.mjs.snap +++ b/curriculum/schema/__snapshots__/challenge-schema.test.mjs.snap @@ -834,7 +834,44 @@ exports[`challenge schema > should not be changed without informing the mobile t "items": [ { "keys": { - "data": { + "contents": { + "type": "any", + "whens": [ + { + "is": { + "allow": [ + { + "override": true, + }, + "paragraph", + ], + "flags": { + "only": true, + "presence": "required", + }, + "type": "any", + }, + "otherwise": { + "flags": { + "presence": "forbidden", + }, + "type": "any", + }, + "ref": { + "path": [ + "type", + ], + }, + "then": { + "flags": { + "presence": "required", + }, + "type": "string", + }, + }, + ], + }, + "files": { "type": "any", "whens": [ { @@ -853,9 +890,9 @@ exports[`challenge schema > should not be changed without informing the mobile t }, "otherwise": { "flags": { - "presence": "required", + "presence": "forbidden", }, - "type": "string", + "type": "any", }, "ref": { "path": [ diff --git a/curriculum/schema/challenge-schema.js b/curriculum/schema/challenge-schema.js index a7c849e690e..78e5accfaeb 100644 --- a/curriculum/schema/challenge-schema.js +++ b/curriculum/schema/challenge-schema.js @@ -205,7 +205,7 @@ export const schema = Joi.object().keys({ nodules: Joi.array().items( Joi.object().keys({ type: Joi.valid('paragraph', 'interactiveEditor').required(), - data: Joi.when('type', { + files: Joi.when('type', { is: ['interactiveEditor'], then: Joi.array().items( Joi.object().keys({ @@ -215,7 +215,12 @@ export const schema = Joi.object().keys({ contentsHtml: Joi.string().required() }) ), - otherwise: Joi.string().required() + otherwise: Joi.forbidden() + }), + contents: Joi.when('type', { + is: ['paragraph'], + then: Joi.string().required(), + otherwise: Joi.forbidden() }) }) ), diff --git a/e2e/interactive-editor.spec.ts b/e2e/interactive-editor.spec.ts index 0961ba5925d..ea5839b9dff 100644 --- a/e2e/interactive-editor.spec.ts +++ b/e2e/interactive-editor.spec.ts @@ -1,77 +1,31 @@ import { test, expect } from '@playwright/test'; -interface InteractiveFile { - contents: string; - ext: string; - name: string; - contentsHtml: string; -} +const html = { + challengePath: + '/learn/responsive-web-design-v9/lecture-what-is-css/what-are-some-default-browser-styles-applied-to-html', + challengeTitle: 'What Are Some Default Browser Styles Applied to HTML?', + paragraphOneText: + 'When you start working with HTML and CSS, you\'ll notice that some styles are applied to your web pages even before you write any CSS. These styles are called "default browser styles" or "user-agent styles".', + codeSnippet: '

Heading 1

' +}; -interface Nodule { - type: 'paragraph' | 'interactiveEditor'; - data: string | InteractiveFile[]; -} - -interface PageData { - result: { - data: { - challengeNode: { - challenge: { - title: string; - nodules: Nodule[]; - }; - }; - }; - }; -} - -const challengePath = - '/learn/responsive-web-design-v9/lecture-what-is-css/what-are-some-default-browser-styles-applied-to-html'; - -const challengeTitle = 'Test Challenge Title'; +const js = { + challengePath: + '/learn/javascript-v9/lecture-introduction-to-javascript/what-is-a-data-type' +}; test.describe('Interactive Editor', () => { - test('should render paragraph nodules as text and not show the interactive editor toggle', async ({ + // skipping because I don't know of a page with paragraph nodules that doesn't also have an interactive editor toggle + test.skip('should render paragraph nodules as text and not show the interactive editor toggle', async ({ page }) => { - await page.route( - `**/page-data${challengePath}/page-data.json`, - async route => { - const response = await route.fetch(); - const body = await response.text(); - const pageData = JSON.parse(body) as PageData; - - pageData.result.data.challengeNode.challenge.title = challengeTitle; - pageData.result.data.challengeNode.challenge.nodules = [ - { - type: 'paragraph', - data: '

This is a plain text paragraph.

' - }, - { - type: 'paragraph', - data: '

Another paragraph with code in it.

' - } - ]; - - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify(pageData) - }); - } - ); - - await page.goto(challengePath); + await page.goto(html.challengePath); await expect( - page.getByRole('heading', { name: challengeTitle }) + page.getByRole('heading', { name: html.challengeTitle }) ).toBeVisible(); - await expect( - page.getByText('This is a plain text paragraph.') - ).toBeVisible(); - await expect( - page.getByText('Another paragraph with code in it.') - ).toBeVisible(); + await expect(page.getByText(html.paragraphOneText)).toBeVisible(); await expect( page.getByRole('button', { name: /interactive editor/i }) @@ -81,72 +35,18 @@ test.describe('Interactive Editor', () => { test('should toggle between interactive editor and static code view when Interactive Editor button is clicked', async ({ page }) => { - await page.route( - `**/page-data${challengePath}/page-data.json`, - async route => { - const response = await route.fetch(); - const body = await response.text(); - const pageData = JSON.parse(body) as PageData; - - pageData.result.data.challengeNode.challenge.title = challengeTitle; - pageData.result.data.challengeNode.challenge.nodules = [ - { - type: 'paragraph', - data: '

Introduction paragraph.

' - }, - { - type: 'interactiveEditor', - data: [ - { - contents: 'console.log("Toggle test");', - ext: 'js', - name: 'script-1', - contentsHtml: - '
console.log("Toggle test");
' - }, - { - contents: '
HTML content
', - ext: 'html', - name: 'index-1', - contentsHtml: - '
<div>HTML content</div>
' - } - ] - }, - { - type: 'paragraph', - data: '

Final paragraph.

' - } - ]; - - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify(pageData) - }); - } - ); - - await page.goto(challengePath); + await page.goto(html.challengePath); await expect( - page.getByRole('heading', { name: challengeTitle }) + page.getByRole('heading', { name: html.challengeTitle }) ).toBeVisible(); - await expect(page.getByText('Introduction paragraph.')).toBeVisible(); - await expect(page.getByText('Final paragraph.')).toBeVisible(); + await expect(page.getByText(html.paragraphOneText)).toBeVisible(); // Initially, interactive editor should be hidden, static code view should be visible await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible(); await expect( - page - .locator('pre code') - .filter({ hasText: 'console.log("Toggle test");' }) + page.locator('pre code').filter({ hasText: html.codeSnippet }) ).toHaveCount(1); - await expect( - page.locator('pre code').filter({ hasText: '
HTML content
' }) - ).toHaveCount(1); - await expect( - page.evaluate(() => localStorage.getItem('showInteractiveEditor')) - ).resolves.toBe(null); // Click the toggle button const toggleButton = page.getByRole('button', { @@ -155,11 +55,10 @@ test.describe('Interactive Editor', () => { await toggleButton.click(); // Interactive editor should be visible, static code view hidden - await expect(page.getByTestId('sp-interactive-editor')).toBeVisible(); - await expect(page.locator('pre code')).not.toBeVisible(); await expect( - page.evaluate(() => localStorage.getItem('showInteractiveEditor')) - ).resolves.toBe('true'); + page.getByTestId('sp-interactive-editor').first() + ).toBeVisible(); + await expect(page.locator('pre code')).not.toBeVisible(); // Click the toggle button again await toggleButton.click(); @@ -167,69 +66,39 @@ test.describe('Interactive Editor', () => { // Interactive editor should be hidden, static code view visible again await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible(); await expect( - page - .locator('pre code') - .filter({ hasText: 'console.log("Toggle test");' }) + page.locator('pre code').filter({ hasText: html.codeSnippet }) ).toBeVisible(); - await expect( - page.locator('pre code').filter({ hasText: '
HTML content
' }) - ).toBeVisible(); - await expect( - page.evaluate(() => localStorage.getItem('showInteractiveEditor')) - ).resolves.toBe('false'); }); - test('should hide console panel in JS-only interactive editor to prevent output duplication', async ({ + test('should persist the preference for showing the interactive editor', async ({ page }) => { - await page.route( - `**/page-data${challengePath}/page-data.json`, - async route => { - const response = await route.fetch(); - const body = await response.text(); - const pageData = JSON.parse(body) as PageData; + await page.goto(html.challengePath); - pageData.result.data.challengeNode.challenge.title = challengeTitle; - pageData.result.data.challengeNode.challenge.nodules = [ - { - type: 'paragraph', - data: '

This challenge has only JavaScript code.

' - }, - { - type: 'interactiveEditor', - data: [ - { - contents: 'console.log("Hello from JS-only editor");', - ext: 'js', - name: 'script-1', - contentsHtml: - '
console.log("Hello from JS-only editor");
' - } - ] - } - ]; + // Initially, the interactive editor should be hidden + await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible(); - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify(pageData) - }); - } - ); - - await page.goto(challengePath); + await page + .getByRole('button', { + name: /interactive editor/i + }) + .click(); await expect( - page.getByRole('heading', { name: challengeTitle }) - ).toBeVisible(); - await expect( - page.getByText('This challenge has only JavaScript code.') + page.getByTestId('sp-interactive-editor').first() ).toBeVisible(); + // Reload the page and check that the interactive editor is still shown + await page.reload(); await expect( - page - .locator('pre code') - .filter({ hasText: 'console.log("Hello from JS-only editor");' }) + page.getByTestId('sp-interactive-editor').first() ).toBeVisible(); + }); + + test('should hide the preview panel in JS-only interactive editors and just show the console', async ({ + page + }) => { + await page.goto(js.challengePath); // Click the toggle button to show interactive editor await page @@ -238,8 +107,15 @@ test.describe('Interactive Editor', () => { }) .click(); - // Check that the console is visible and the console wrapper is hidden - await expect(page.locator('.sp-console')).toBeVisible(); - await expect(page.locator('.sp-console-wrapper')).not.toBeVisible(); + // Check that the consoles are visible + const consoles = page.getByTestId('sp-console'); + await expect(consoles).toHaveCount(4); + for (const console of await consoles.all()) { + await expect(console).toBeVisible(); + } + + // Check that the preview is not visible + const previews = page.getByTestId('sp-preview'); + await expect(previews).not.toBeVisible(); }); }); diff --git a/tools/challenge-parser/parser/plugins/add-interactive-elements.js b/tools/challenge-parser/parser/plugins/add-interactive-elements.js index 21ab46d4bbb..60a0e022f85 100644 --- a/tools/challenge-parser/parser/plugins/add-interactive-elements.js +++ b/tools/challenge-parser/parser/plugins/add-interactive-elements.js @@ -27,13 +27,13 @@ function plugin() { ) { return { type: 'interactiveEditor', - data: getFiles(node.children) + files: getFiles(node.children) }; } else { const paragraph = mdastToHTML([node]); return { type: 'paragraph', - data: paragraph + contents: paragraph }; } }) ?? []; diff --git a/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js b/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js index 1bdc7167a1d..3194318ea4a 100644 --- a/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js +++ b/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js @@ -21,6 +21,23 @@ describe('add-interactive-editor plugin', () => { expect(Array.isArray(file.data.nodules)).toBe(true); }); + it('converts paragraphs to paragraph nodes', async () => { + const mockAST = await parseFixture('with-interactive.md'); + plugin(mockAST, file); + + expect(file.data.nodules.slice(0, 2)).toEqual([ + { + type: 'paragraph', + contents: '

Normal markdown

' + }, + { + type: 'paragraph', + contents: + '
<div>This is NOT an interactive element</div>\n
' + } + ]); + }); + it('populates `nodules` with editor objects', async () => { const mockAST = await parseFixture('with-interactive.md'); plugin(mockAST, file); @@ -31,7 +48,7 @@ describe('add-interactive-editor plugin', () => { expect(editorElements).toEqual([ { type: 'interactiveEditor', - data: [ + files: [ { contents: "console.log('Interactive JS');", ext: 'js', @@ -43,7 +60,7 @@ describe('add-interactive-editor plugin', () => { }, { type: 'interactiveEditor', - data: [ + files: [ { contents: '
This is an interactive element
', ext: 'html', @@ -55,7 +72,7 @@ describe('add-interactive-editor plugin', () => { }, { type: 'interactiveEditor', - data: [ + files: [ { contents: '
This is an interactive element
', ext: 'html', @@ -84,7 +101,7 @@ describe('add-interactive-editor plugin', () => { expect(editorElements).toHaveLength(1); - const files = editorElements[0].data; + const { files } = editorElements[0]; expect(files).toHaveLength(2); // Both files should be JavaScript but have unique names diff --git a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js index 672df17fc8f..cd4df0f6828 100644 --- a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js +++ b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js @@ -26,20 +26,6 @@ const idToFilepath = new Map(); // recently overwritten files const idToOverwrittenFile = new Map(); -exports.createSchemaCustomization = ({ actions }) => { - const { createTypes } = actions; - const typeDefs = ` - type QuizQuestion { - text: String! - distractors: [String!]! - answer: String! - audioId: String - transcript: String - } - `; - createTypes(typeDefs); -}; - exports.sourceNodes = function sourceChallengesSourceNodes( { actions, reporter, createNodeId, createContentDigest }, pluginOptions