refactor: update gatsby schema, remove customization + use inferrable type (#65857)

This commit is contained in:
Oliver Eyton-Williams
2026-03-03 10:48:53 +01:00
committed by GitHub
parent 3250669a2b
commit e902fd270f
11 changed files with 581 additions and 490 deletions

View File

@@ -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);
};

View File

@@ -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 <GatsbyImage /> 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

View File

@@ -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;

View File

@@ -102,8 +102,12 @@ const InteractiveEditor = ({ files }: Props) => {
{layout === 'preview' ? (
showConsole ? (
<>
<SandpackPreview style={{ flex: 1.5 }} />
<SandpackPreview
data-playwright-test-label='sp-preview'
style={{ flex: 1.5 }}
/>
<SandpackConsole
data-playwright-test-label='sp-console'
style={{
flex: 1,
overflow: 'scroll'
@@ -114,7 +118,10 @@ const InteractiveEditor = ({ files }: Props) => {
<SandpackPreview />
)
) : (
<SandpackConsole standalone={true} />
<SandpackConsole
data-playwright-test-label='sp-console'
standalone={true}
/>
)}
</SandpackStack>
</SandpackLayout>

View File

@@ -77,12 +77,12 @@ function renderNodule(
) {
switch (nodule.type) {
case 'paragraph':
return <PrismFormatted text={nodule.data} />;
return <PrismFormatted text={nodule.contents} />;
case 'interactiveEditor':
if (showInteractiveEditor) {
return <InteractiveEditor files={nodule.data} />;
return <InteractiveEditor files={nodule.files} />;
} else {
const files = nodule.data;
const { files } = nodule;
return files.map((file, index) => (
<PrismFormatted key={index} text={file.contentsHtml} />
));
@@ -390,7 +390,13 @@ export const query = graphql`
description
nodules {
type
data
contents
files {
ext
name
contents
contentsHtml
}
}
explanation
helpCategory

View File

@@ -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": [

View File

@@ -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()
})
})
),

View File

@@ -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: '<h1>Heading 1</h1>'
};
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: '<p>This is a plain text paragraph.</p>'
},
{
type: 'paragraph',
data: '<p>Another paragraph with <code>code</code> in it.</p>'
}
];
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: '<p>Introduction paragraph.</p>'
},
{
type: 'interactiveEditor',
data: [
{
contents: 'console.log("Toggle test");',
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-javascript">console.log("Toggle test");</code></pre>'
},
{
contents: '<div>HTML content</div>',
ext: 'html',
name: 'index-1',
contentsHtml:
'<pre><code class="language-html">&lt;div&gt;HTML content&lt;/div&gt;</code></pre>'
}
]
},
{
type: 'paragraph',
data: '<p>Final paragraph.</p>'
}
];
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: '<div>HTML content</div>' })
).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: '<div>HTML content</div>' })
).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: '<p>This challenge has only JavaScript code.</p>'
},
{
type: 'interactiveEditor',
data: [
{
contents: 'console.log("Hello from JS-only editor");',
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-javascript">console.log("Hello from JS-only editor");</code></pre>'
}
]
}
];
// 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();
});
});

View File

@@ -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
};
}
}) ?? [];

View File

@@ -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: '<p>Normal markdown</p>'
},
{
type: 'paragraph',
contents:
'<pre><code class="language-html">&#x3C;div>This is NOT an interactive element&#x3C;/div>\n</code></pre>'
}
]);
});
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: '<div>This is an interactive element</div>',
ext: 'html',
@@ -55,7 +72,7 @@ describe('add-interactive-editor plugin', () => {
},
{
type: 'interactiveEditor',
data: [
files: [
{
contents: '<div>This is an interactive element</div>',
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

View File

@@ -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