refactor: files{} -> challengeFiles[], and key -> fileKey (#43023)

* fix(client): fix client

* fix propType and add comment

* revert user.json prettification

* slight type refactor and payload correction

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* update ChallengeFile type imports

* add cypress test for code-storage

* update test and storage epic

* fix Shaun's tired brain's logic

* refactor with suggestions

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* update codeReset

* increate cypress timeout because firefox is slow

* remove unused import to make linter happy

* use focus on editor

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* use more specific seletor for cypress editor test

* account for silly null challengeFiles

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2021-08-12 19:48:28 +01:00
committed by GitHub
parent 1f62dfe2b3
commit 59f17f237b
41 changed files with 916 additions and 910 deletions

View File

@ -244,17 +244,11 @@ exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions;
const typeDefs = `
type ChallengeNode implements Node {
files: ChallengeFile
challengeFiles: [FileContents]
url: String
}
type ChallengeFile {
indexcss: FileContents
indexhtml: FileContents
indexjs: FileContents
indexjsx: FileContents
}
type FileContents {
key: String
fileKey: String
ext: String
name: String
contents: String

View File

@ -5,7 +5,7 @@ import { Trans, useTranslation } from 'react-i18next';
import ProjectModal from '../components/SolutionViewer/ProjectModal';
import { Spacer, Link } from '../components/helpers';
import {
ChallengeFileType,
ChallengeFiles,
CompletedChallenge,
UserType
} from '../redux/prop-types';
@ -24,14 +24,14 @@ interface IShowProjectLinksProps {
type SolutionStateType = {
projectTitle: string;
files?: ChallengeFileType[] | null;
challengeFiles: ChallengeFiles;
solution: CompletedChallenge['solution'];
isOpen: boolean;
};
const initSolutionState: SolutionStateType = {
projectTitle: '',
files: null,
challengeFiles: null,
solution: null,
isOpen: false
};
@ -56,16 +56,16 @@ const ShowProjectLinks = (props: IShowProjectLinksProps): JSX.Element => {
return null;
}
const { solution, githubLink, files } = completedProject;
const { solution, githubLink, challengeFiles } = completedProject;
const onClickHandler = () =>
setSolutionState({
projectTitle,
files,
challengeFiles,
solution,
isOpen: true
});
if (files?.length) {
if (challengeFiles?.length) {
return (
<button
className='project-link-button-override'
@ -163,7 +163,7 @@ const ShowProjectLinks = (props: IShowProjectLinksProps): JSX.Element => {
name,
user: { username }
} = props;
const { files, isOpen, projectTitle, solution } = solutionState;
const { challengeFiles, isOpen, projectTitle, solution } = solutionState;
return (
<div>
{t(
@ -177,7 +177,7 @@ const ShowProjectLinks = (props: IShowProjectLinksProps): JSX.Element => {
<Spacer />
{isOpen ? (
<ProjectModal
files={files}
challengeFiles={challengeFiles}
handleSolutionModalHide={handleSolutionModalHide}
isOpen={isOpen}
projectTitle={projectTitle}

View File

@ -5,15 +5,16 @@ import { useTranslation } from 'react-i18next';
import SolutionViewer from './SolutionViewer';
const propTypes = {
files: PropTypes.arrayOf(
PropTypes.shape({
contents: PropTypes.string,
ext: PropTypes.string,
key: PropTypes.string,
name: PropTypes.string,
path: PropTypes.string
})
),
challengeFiles: PropTypes.array,
// TODO: removed once refactored to TS
// PropTypes.shape({
// contents: PropTypes.string,
// ext: PropTypes.string,
// key: PropTypes.string,
// name: PropTypes.string,
// path: PropTypes.string
// })
// ),
handleSolutionModalHide: PropTypes.func,
isOpen: PropTypes.bool,
projectTitle: PropTypes.string,
@ -21,8 +22,13 @@ const propTypes = {
};
const ProjectModal = props => {
const { isOpen, projectTitle, files, solution, handleSolutionModalHide } =
props;
const {
isOpen,
projectTitle,
challengeFiles,
solution,
handleSolutionModalHide
} = props;
const { t } = useTranslation();
return (
<Modal
@ -39,7 +45,7 @@ const ProjectModal = props => {
</Modal.Title>
</Modal.Header>
<Modal.Body>
<SolutionViewer files={files} solution={solution} />
<SolutionViewer challengeFiles={challengeFiles} solution={solution} />
</Modal.Body>
<Modal.Footer>
<Button onClick={handleSolutionModalHide}>{t('buttons.close')}</Button>

View File

@ -11,21 +11,25 @@ const prismLang = {
};
const SolutionViewer = ({
files,
challengeFiles,
solution = '// The solution is not available for this project'
}) =>
files && Array.isArray(files) && files.length ? (
files.map(file => (
<Panel bsStyle='primary' className='solution-viewer' key={file.ext}>
<Panel.Heading>{file.ext.toUpperCase()}</Panel.Heading>
challengeFiles?.length ? (
challengeFiles.map(challengeFile => (
<Panel
bsStyle='primary'
className='solution-viewer'
key={challengeFile.ext}
>
<Panel.Heading>{challengeFile.ext.toUpperCase()}</Panel.Heading>
<Panel.Body>
<pre>
<code
className={`language-${prismLang[file.ext]}`}
className={`language-${prismLang[challengeFile.ext]}`}
dangerouslySetInnerHTML={{
__html: Prism.highlight(
file.contents.trim(),
Prism.languages[prismLang[file.ext]]
challengeFile.contents.trim(),
Prism.languages[prismLang[challengeFile.ext]]
)
}}
/>
@ -59,7 +63,7 @@ const SolutionViewer = ({
SolutionViewer.displayName = 'SolutionViewer';
SolutionViewer.propTypes = {
files: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.string)),
challengeFiles: PropTypes.array,
solution: PropTypes.string
};

View File

@ -80,7 +80,7 @@ describe('<TimeLine />', () => {
const contents = 'This is not JS';
const ext = 'js';
const key = 'indexjs';
const fileKey = 'indexjs';
const name = 'index';
const path = 'index.js';
@ -100,15 +100,7 @@ const propsForOnlySolution = {
{
id: '5e46f7f8ac417301a38fb92a',
completedDate: 1604043678032,
files: [
{
contents,
ext,
key,
name,
path
}
]
challengeFiles: [{ contents, ext, fileKey, name, path }]
}
],
username: 'developmentuser'

View File

@ -1,8 +1,3 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/unbound-method */
import {
Button,
@ -25,6 +20,7 @@ import {
getTitleFromId
} from '../../../../../utils';
import CertificationIcon from '../../../assets/icons/certification-icon';
import { ChallengeFiles } from '../../../redux/prop-types';
import { maybeUrlRE } from '../../../utils';
import { FullWidthRow, Link } from '../../helpers';
import TimelinePagination from './TimelinePagination';
@ -32,70 +28,54 @@ import TimelinePagination from './TimelinePagination';
import './timeline.css';
const SolutionViewer = Loadable(
() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import('../../SolutionViewer/SolutionViewer')
() => import('../../SolutionViewer/SolutionViewer')
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { clientLocale } = envData;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access
const { clientLocale } = envData as { clientLocale: keyof typeof langCodes };
const localeCode = langCodes[clientLocale];
// Items per page in timeline.
const ITEMS_PER_PAGE = 15;
interface ICompletedMap {
interface CompletedMap {
id: string;
completedDate: number;
challengeType: number;
solution: string;
files: IFile[];
challengeFiles: ChallengeFiles;
githubLink: string;
}
interface ITimelineProps {
completedMap: ICompletedMap[];
interface TimelineProps {
completedMap: CompletedMap[];
t: TFunction;
username: string;
}
interface IFile {
ext: string;
contents: string;
}
interface ISortedTimeline {
interface SortedTimeline {
id: string;
completedDate: number;
files: IFile[];
challengeFiles: ChallengeFiles;
githubLink: string;
solution: string;
}
interface ITimelineInnerProps extends ITimelineProps {
interface TimelineInnerProps extends TimelineProps {
idToNameMap: Map<string, string>;
sortedTimeline: ISortedTimeline[];
sortedTimeline: SortedTimeline[];
totalPages: number;
}
interface ITimeLineInnerState {
interface TimeLineInnerState {
solutionToView: string | null;
solutionOpen: boolean;
pageNo: number;
solution: string | null;
files: IFile[] | null;
challengeFiles: ChallengeFiles;
}
class TimelineInner extends Component<
ITimelineInnerProps,
ITimeLineInnerState
> {
constructor(props: ITimelineInnerProps) {
class TimelineInner extends Component<TimelineInnerProps, TimeLineInnerState> {
constructor(props: TimelineInnerProps) {
super(props);
this.state = {
@ -103,7 +83,7 @@ class TimelineInner extends Component<
solutionOpen: false,
pageNo: 1,
solution: null,
files: null
challengeFiles: null
};
this.closeSolution = this.closeSolution.bind(this);
@ -118,19 +98,19 @@ class TimelineInner extends Component<
renderViewButton(
id: string,
files: IFile[],
challengeFiles: ChallengeFiles,
githubLink: string,
solution: string
): React.ReactNode {
const { t } = this.props;
if (files && files.length) {
if (challengeFiles?.length) {
return (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
id={`btn-for-${id}`}
onClick={() => this.viewSolution(id, solution, files)}
onClick={() => this.viewSolution(id, solution, challengeFiles)}
>
{t('buttons.show-code')}
</Button>
@ -183,12 +163,11 @@ class TimelineInner extends Component<
}
}
renderCompletion(completed: ISortedTimeline): JSX.Element {
renderCompletion(completed: SortedTimeline): JSX.Element {
const { idToNameMap, username } = this.props;
const { id, files, githubLink, solution } = completed;
const { id, challengeFiles, githubLink, solution } = completed;
const completedDate = new Date(completed.completedDate);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error idToNameMap is not a <string, string> Map...
const { challengeTitle, challengePath, certPath } = idToNameMap.get(id);
return (
<tr className='timeline-row' key={id}>
@ -196,16 +175,18 @@ class TimelineInner extends Component<
{certPath ? (
<Link
className='timeline-cert-link'
to={`/certification/${username}/${certPath}`}
to={`/certification/${username}/${certPath as string}`}
>
{challengeTitle}
<CertificationIcon />
</Link>
) : (
<Link to={challengePath}>{challengeTitle}</Link>
<Link to={challengePath as string}>{challengeTitle}</Link>
)}
</td>
<td>{this.renderViewButton(id, files, githubLink, solution)}</td>
<td>
{this.renderViewButton(id, challengeFiles, githubLink, solution)}
</td>
<td className='text-center'>
<time dateTime={completedDate.toISOString()}>
{completedDate.toLocaleString([localeCode, 'en-US'], {
@ -218,13 +199,17 @@ class TimelineInner extends Component<
</tr>
);
}
viewSolution(id: string, solution: string, files: IFile[]): void {
viewSolution(
id: string,
solution: string,
challengeFiles: ChallengeFiles
): void {
this.setState(state => ({
...state,
solutionToView: id,
solutionOpen: true,
solution,
files
challengeFiles
}));
}
@ -234,7 +219,7 @@ class TimelineInner extends Component<
solutionToView: null,
solutionOpen: false,
solution: null,
files: null
challengeFiles: null
}));
}
@ -306,15 +291,14 @@ class TimelineInner extends Component<
<Modal.Title id='contained-modal-title'>
{`${username}'s Solution to ${
// @ts-expect-error Need better TypeDef for this
idToNameMap.get(id).challengeTitle
idToNameMap.get(id).challengeTitle as string
}`}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<SolutionViewer
// @ts-expect-error Need Better TypeDef
files={this.state.files}
solution={this.state.solution}
challengeFiles={this.state.challengeFiles}
solution={this.state.solution ?? ''}
/>
</Modal.Body>
<Modal.Footer>
@ -336,7 +320,7 @@ class TimelineInner extends Component<
);
}
}
/* 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<string, string> {
const {
allChallengeNode: { edges }
@ -365,22 +349,22 @@ function useIdToNameMap(): Map<string, string> {
edges.forEach(
({
node: {
// @ts-ignore
// @ts-expect-error Graphql needs typing
id,
// @ts-ignore
// @ts-expect-error Graphql needs typing
title,
// @ts-ignore
// @ts-expect-error Graphql needs typing
fields: { slug }
}
}) => {
idToNameMap.set(id, { challengeTitle: title, challengePath: slug });
}
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return idToNameMap;
/* eslint-enable */
}
const Timeline = (props: ITimelineProps): JSX.Element => {
const Timeline = (props: TimelineProps): JSX.Element => {
const idToNameMap = useIdToNameMap();
const { completedMap } = props;
// Get the sorted timeline along with total page count.

View File

@ -32,7 +32,7 @@ const propTypes = {
githubLink: PropTypes.string,
challengeType: PropTypes.number,
completedDate: PropTypes.number,
files: PropTypes.array
challengeFiles: PropTypes.array
})
),
createFlashMessage: PropTypes.func.isRequired,
@ -136,7 +136,7 @@ const honestyInfoMessage = {
const initialState = {
solutionViewer: {
projectTitle: '',
files: null,
challengeFiles: null,
solution: null,
isOpen: false
}
@ -167,17 +167,17 @@ export class CertificationSettings extends Component {
if (!completedProject) {
return null;
}
const { solution, githubLink, files } = completedProject;
const { solution, githubLink, challengeFiles } = completedProject;
const onClickHandler = () =>
this.setState({
solutionViewer: {
projectTitle,
files,
challengeFiles,
solution,
isOpen: true
}
});
if (files && files.length) {
if (challengeFiles?.length) {
return (
<Button
block={true}
@ -417,7 +417,7 @@ export class CertificationSettings extends Component {
render() {
const {
solutionViewer: { files, solution, isOpen, projectTitle }
solutionViewer: { challengeFiles, solution, isOpen, projectTitle }
} = this.state;
const { t } = this.props;
@ -434,7 +434,7 @@ export class CertificationSettings extends Component {
)}
{isOpen ? (
<ProjectModal
files={files}
challengeFiles={challengeFiles}
handleSolutionModalHide={this.handleSolutionModalHide}
isOpen={isOpen}
projectTitle={projectTitle}

View File

@ -241,7 +241,7 @@ const defaultTestProps = {
const contents = 'This is not JS';
const ext = 'js';
const key = 'indexjs';
const fileKey = 'indexjs';
const name = 'index';
const path = 'index.js';
@ -259,11 +259,11 @@ const propsForOnlySolution = {
},
{
id: '5e46f7f8ac417301a38fb92a',
files: [
challengeFiles: [
{
contents,
ext,
key,
fileKey,
name,
path
}

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import { HandlerProps } from 'react-reflex';
const FileType = PropTypes.shape({
export const FileType = PropTypes.shape({
key: PropTypes.string,
ext: PropTypes.string,
name: PropTypes.string,
@ -24,10 +25,7 @@ export const ChallengeNode = PropTypes.shape({
challengeType: PropTypes.number,
dashedName: PropTypes.string,
description: PropTypes.string,
files: PropTypes.shape({
indexhtml: FileType,
indexjs: FileType
}),
challengeFiles: PropTypes.array,
fields: PropTypes.shape({
slug: PropTypes.string,
blockName: PropTypes.string
@ -83,7 +81,7 @@ export const User = PropTypes.shape({
githubLink: PropTypes.string,
challengeType: PropTypes.number,
completedDate: PropTypes.number,
files: PropTypes.array
challengeFiles: PropTypes.array
})
),
email: PropTypes.string,
@ -176,19 +174,24 @@ export type MarkdownRemarkType = {
words: number;
};
};
type Question = { text: string; answers: string[]; solution: number };
type Fields = { slug: string; blockName: string; tests: Test[] };
type Required = {
link: string;
raw: boolean;
src: string;
crossDomain?: boolean;
};
export type ChallengeNodeType = {
block: string;
challengeOrder: number;
challengeType: number;
dashedName: string;
description: string;
challengeFiles: ChallengeFileType[];
fields: {
slug: string;
blockName: string;
tests: TestType[];
};
files: ChallengeFileType;
challengeFiles: ChallengeFiles;
fields: Fields;
forumTopicId: number;
guideUrl: string;
head: string[];
@ -200,18 +203,8 @@ export type ChallengeNodeType = {
isLocked: boolean;
isPrivate: boolean;
order: number;
question: {
text: string;
answers: string[];
solution: number;
};
required: [
{
link: string;
raw: string;
src: string;
}
];
question: Question;
required: Required[];
superOrder: number;
superBlock: string;
tail: string[];
@ -240,7 +233,7 @@ export type AllMarkdownRemarkType = {
};
export type ResizePropsType = {
onStopResize: (arg0: React.ChangeEvent) => void;
onStopResize: (arg0: HandlerProps) => void;
onResize: () => void;
};
@ -249,11 +242,19 @@ export type DimensionsType = {
width: number;
};
export type TestType = {
text: string;
testString: string;
export type Test = {
pass?: boolean;
err?: string;
} & (ChallengeTest | CertTest);
export type ChallengeTest = {
text: string;
testString: string;
};
export type CertTest = {
id: string;
title: string;
};
export type UserType = {
@ -311,39 +312,12 @@ export type CompletedChallenge = {
githubLink?: string;
challengeType?: number;
completedDate: number;
challengeFiles: ChallengeFileType[] | null;
// TODO: remove once files->challengeFiles is refactored
files?: ChallengeFileType[] | null;
challengeFiles: ChallengeFiles;
};
// TODO: renames: files => challengeFiles; key => fileKey; #42489
export type ChallengeFileType =
| {
[T in FileKeyTypes]:
| ({
editableContents: string;
editableRegionBoundaries: number[];
error?: string | null;
history: string[];
path: string;
seed: string;
seedEditableRegionBoundaries?: number[];
} & FileKeyChallengeType)
| null;
}
| Record<string, never>;
export type ExtTypes = 'js' | 'html' | 'css' | 'jsx';
export type FileKeyTypes = 'indexjs' | 'indexhtml' | 'indexcss';
export type ChallengeFilesType =
| {
indexcss: ChallengeFileType;
indexhtml: ChallengeFileType;
indexjs: ChallengeFileType;
indexjsx: ChallengeFileType;
}
| Record<string, never>;
export type ChallengeMetaType = {
block: string;
id: string;
@ -383,7 +357,7 @@ export type FileKeyChallengeType = {
// think are on the node, but actually do not exist.
export type ChallengeNode = {
block: string;
challengeFiles: ChallengeFileType;
challengeFiles: ChallengeFiles;
challengeOrder: number;
challengeType: number;
dashedName: string;
@ -391,7 +365,7 @@ export type ChallengeNode = {
fields: {
slug: string;
blockName: string;
tests: TestType[];
tests: Test[];
};
forumTopicId: number;
// guideUrl: string;
@ -430,7 +404,7 @@ export type ChallengeNode = {
superBlock: string;
superOrder: number;
template: string;
tests: TestType[];
tests: Test[];
time: string;
title: string;
translationPending: boolean;
@ -441,3 +415,61 @@ export type ChallengeNode = {
// isPrivate: boolean;
// tail: string[];
};
// Extra types built from challengeSchema
export type ChallengeFile = {
fileKey: string;
ext: ExtTypes;
name: string;
editableRegionBoundaries: number[];
path: string;
error: null | string;
head: string;
tail: string;
seed: string;
contents: string;
id: string;
history: [[string], string];
};
export type ChallengeFiles = ChallengeFile[] | null;
export interface ChallengeSchema {
block: string;
blockId: string;
challengeOrder: number;
removeComments: boolean;
// TODO: should be typed with possible values
challengeType: number;
checksum: number;
__commentCounts: Record<string, unknown>;
dashedName: string;
description: string;
challengeFiles: ChallengeFiles;
guideUrl: string;
// TODO: should be typed with possible values
helpCategory: string;
videoUrl: string;
forumTopicId: number;
id: string;
instructions: string;
isComingSoon: boolean;
// TODO: Do we still use this
isLocked: boolean;
isPrivate: boolean;
order: number;
videoId?: string;
question: Question;
required: Required[];
solutions: ChallengeFile[][];
superBlock: string;
superOrder: number;
suborder: number;
tests: Test[];
template: string;
time: string;
title: string;
translationPending: boolean;
url?: string;
}

View File

@ -14,7 +14,7 @@ const paneType = {
const propTypes = {
block: PropTypes.string,
challengeFiles: PropTypes.object,
challengeFiles: PropTypes.array,
editor: PropTypes.element,
hasEditableBoundries: PropTypes.bool,
hasPreview: PropTypes.bool,
@ -58,7 +58,7 @@ class DesktopLayout extends Component {
getChallengeFile() {
const { challengeFiles } = this.props;
return first(Object.keys(challengeFiles).map(key => challengeFiles[key]));
return first(challengeFiles);
}
render() {
@ -121,7 +121,7 @@ class DesktopLayout extends Component {
!hasEditableBoundries && <EditorTabs />}
{challengeFile && (
<ReflexContainer
key={challengeFile.key}
key={challengeFile.fileKey}
orientation='horizontal'
>
<ReflexElement

View File

@ -10,7 +10,7 @@ import {
} from '../redux';
const propTypes = {
challengeFiles: PropTypes.object.isRequired,
challengeFiles: PropTypes.array.isRequired,
toggleVisibleEditor: PropTypes.func.isRequired,
visibleEditors: PropTypes.shape({
indexjs: PropTypes.bool,
@ -38,46 +38,17 @@ class EditorTabs extends Component {
const { challengeFiles, toggleVisibleEditor, visibleEditors } = this.props;
return (
<div className='monaco-editor-tabs'>
{challengeFiles['indexjsx'] && (
{challengeFiles.map(challengeFile => (
<button
aria-selected={visibleEditors.indexjsx}
aria-selected={visibleEditors[challengeFile.fileKey]}
className='monaco-editor-tab'
onClick={() => toggleVisibleEditor('indexjsx')}
key={challengeFile.fileKey}
onClick={() => toggleVisibleEditor(challengeFile.fileKey)}
role='tab'
>
script.jsx
{challengeFile.path}
</button>
)}
{challengeFiles['indexhtml'] && (
<button
aria-selected={visibleEditors.indexhtml}
className='monaco-editor-tab'
onClick={() => toggleVisibleEditor('indexhtml')}
role='tab'
>
index.html
</button>
)}
{challengeFiles['indexcss'] && (
<button
aria-selected={visibleEditors.indexcss}
className='monaco-editor-tab'
onClick={() => toggleVisibleEditor('indexcss')}
role='tab'
>
styles.css
</button>
)}
{challengeFiles['indexjs'] && (
<button
aria-selected={visibleEditors.indexjs}
className='monaco-editor-tab'
onClick={() => toggleVisibleEditor('indexjs')}
role='tab'
>
script.js
</button>
)}
))}
</div>
);
}

View File

@ -20,7 +20,7 @@ import Editor from './editor';
const propTypes = {
canFocus: PropTypes.bool,
// TODO: use shape
challengeFiles: PropTypes.object,
challengeFiles: PropTypes.array,
containerRef: PropTypes.any.isRequired,
contents: PropTypes.string,
description: PropTypes.string,
@ -39,6 +39,7 @@ const propTypes = {
saveEditorContent: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func,
theme: PropTypes.string,
// TODO: is this used?
title: PropTypes.string,
updateFile: PropTypes.func.isRequired,
visibleEditors: PropTypes.shape({

View File

@ -1,12 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
// Package Utilities
import { graphql } from 'gatsby';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { HandlerProps } from 'react-reflex';
import Media from 'react-responsive';
import { bindActionCreators, Dispatch } from 'redux';
import { createStructuredSelector } from 'reselect';
@ -17,9 +15,10 @@ import { challengeTypes } from '../../../../utils/challenge-types';
import LearnLayout from '../../../components/layouts/learn';
import {
ChallengeNodeType,
ChallengeFileType,
ChallengeFiles,
ChallengeFile,
ChallengeMetaType,
TestType,
Test,
ResizePropsType
} from '../../../redux/prop-types';
import { isContained } from '../../../utils/is-contained';
@ -57,7 +56,7 @@ import '../components/test-frame.css';
// Redux Setup
const mapStateToProps = createStructuredSelector({
files: challengeFilesSelector,
challengeFiles: challengeFilesSelector,
tests: challengeTestsSelector,
output: consoleOutputSelector,
isChallengeCompleted: isChallengeCompletedSelector
@ -81,28 +80,28 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
interface ShowClassicProps {
cancelTests: () => void;
challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFileType) => void;
createFiles: (arg0: ChallengeFile[]) => void;
data: { challengeNode: ChallengeNodeType };
executeChallenge: () => void;
files: ChallengeFileType;
challengeFiles: ChallengeFiles;
initConsole: (arg0: string) => void;
initTests: (tests: TestType[]) => void;
initTests: (tests: Test[]) => void;
isChallengeCompleted: boolean;
output: string[];
pageContext: {
challengeMeta: ChallengeMetaType;
};
t: TFunction;
tests: TestType[];
tests: Test[];
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
}
interface ShowClassicState {
layout: IReflexLayout | string;
layout: ReflexLayout | string;
resizing: boolean;
}
interface IReflexLayout {
interface ReflexLayout {
codePane: { flex: number };
editorPane: { flex: number };
instructionPane: { flex: number };
@ -147,8 +146,9 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
this.instructionsPanelRef = React.createRef();
}
getLayoutState(): IReflexLayout | string {
const reflexLayout: IReflexLayout | string = store.get(REFLEX_LAYOUT);
getLayoutState(): ReflexLayout | string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const reflexLayout: ReflexLayout | string = store.get(REFLEX_LAYOUT);
// Validate if user has not done any resize of the panes
if (!reflexLayout) return BASE_LAYOUT;
@ -168,8 +168,8 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
this.setState(state => ({ ...state, resizing: true }));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStopResize(event: any) {
onStopResize(event: HandlerProps) {
// @ts-expect-error TODO: Apparently, name does not exist on type
const { name, flex } = event.component.props;
// Only interested in tracking layout updates for ReflexElement's
@ -236,7 +236,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
updateChallengeMeta,
data: {
challengeNode: {
files,
challengeFiles,
fields: { tests },
challengeType,
removeComments,
@ -246,7 +246,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
pageContext: { challengeMeta }
} = this.props;
initConsole('');
createFiles(files);
createFiles(challengeFiles ?? []);
initTests(tests);
updateChallengeMeta({
...challengeMeta,
@ -260,7 +260,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
componentWillUnmount() {
const { createFiles, cancelTests } = this.props;
createFiles({});
createFiles([]);
cancelTests();
}
@ -319,13 +319,13 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
}
renderEditor() {
const { files } = this.props;
const { challengeFiles } = this.props;
const { description, title } = this.getChallenge();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (
files && (
challengeFiles && (
<MultifileEditor
challengeFiles={files}
challengeFiles={challengeFiles}
containerRef={this.containerRef}
description={description}
editorRef={this.editorRef}
@ -358,11 +358,11 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
}
hasEditableBoundries() {
const { files } = this.props;
return Object.values(files).some(
file =>
file?.editableRegionBoundaries &&
file.editableRegionBoundaries.length === 2
const { challengeFiles } = this.props;
return (
challengeFiles?.some(
challengeFile => challengeFile.editableRegionBoundaries?.length === 2
) ?? false
);
}
@ -379,7 +379,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
files,
challengeFiles,
t
} = this.props;
@ -414,7 +414,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
block={block}
challengeFiles={files}
challengeFiles={challengeFiles}
editor={this.renderEditor()}
hasEditableBoundries={this.hasEditableBoundries()}
hasPreview={this.hasPreview()}
@ -478,9 +478,8 @@ export const query = graphql`
link
src
}
files {
indexcss {
key
challengeFiles {
fileKey
ext
name
contents
@ -488,34 +487,6 @@ export const query = graphql`
tail
editableRegionBoundaries
}
indexhtml {
key
ext
name
contents
head
tail
editableRegionBoundaries
}
indexjs {
key
ext
name
contents
head
tail
editableRegionBoundaries
}
indexjsx {
key
ext
name
contents
head
tail
editableRegionBoundaries
}
}
}
}
`;

View File

@ -20,12 +20,12 @@ import store from 'store';
import { Loader } from '../../../components/helpers';
import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import {
ChallengeFileType,
ChallengeFiles,
DimensionsType,
ExtTypes,
FileKeyTypes,
ResizePropsType,
TestType
Test
} from '../../../redux/prop-types';
import {
@ -45,7 +45,7 @@ const MonacoEditor = Loadable(() => import('react-monaco-editor'));
interface EditorProps {
canFocus: boolean;
challengeFiles: ChallengeFileType;
challengeFiles: ChallengeFiles;
containerRef: RefObject<HTMLElement>;
contents: string;
description: string;
@ -61,11 +61,11 @@ interface EditorProps {
saveEditorContent: () => void;
setEditorFocusability: (isFocusable: boolean) => void;
submitChallenge: () => void;
tests: TestType[];
tests: Test[];
theme: string;
title: string;
updateFile: (objest: {
key: FileKeyTypes;
updateFile: (object: {
fileKey: FileKeyTypes;
editorValue: string;
editableRegionBoundaries: number[] | null;
}) => void;
@ -242,7 +242,9 @@ const Editor = (props: EditorProps): JSX.Element => {
const getEditableRegion = () => {
const { challengeFiles, fileKey } = props;
const edRegBounds = challengeFiles[fileKey]?.editableRegionBoundaries;
const edRegBounds = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
)?.editableRegionBoundaries;
return edRegBounds ? [...edRegBounds] : [];
};
@ -256,11 +258,14 @@ const Editor = (props: EditorProps): JSX.Element => {
// swap and reuse models, we have to create our own models to prevent
// disposal.
const challengeFile = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
);
const model =
data.model ||
monaco.editor.createModel(
challengeFiles[fileKey]?.contents ?? '',
modeMap[challengeFiles[fileKey]?.ext ?? 'html']
challengeFile?.contents ?? '',
modeMap[challengeFile?.ext ?? 'html']
);
data.model = model;
const editableRegion = getEditableRegion();
@ -277,7 +282,9 @@ const Editor = (props: EditorProps): JSX.Element => {
const { challengeFiles, fileKey } = props;
const { model } = dataRef.current[fileKey];
const newContents = challengeFiles[fileKey]?.contents;
const newContents = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
)?.contents;
if (model?.getValue() !== newContents) {
model?.setValue(newContents ?? '');
}
@ -576,9 +583,7 @@ const Editor = (props: EditorProps): JSX.Element => {
}
const onChange = (editorValue: string) => {
const { updateFile } = props;
// TODO: use fileKey everywhere?
const { fileKey: key } = props;
const { updateFile, fileKey } = props;
// TODO: now that we have getCurrentEditableRegion, should the overlays
// follow that directly? We could subscribe to changes to that and redraw if
// those imply that the positions have changed (i.e. if the content height
@ -589,7 +594,7 @@ const Editor = (props: EditorProps): JSX.Element => {
editableRegion.startLineNumber - 1,
editableRegion.endLineNumber + 1
];
updateFile({ key, editorValue, editableRegionBoundaries });
updateFile({ fileKey, editorValue, editableRegionBoundaries });
};
function showEditableRegion(editableBoundaries: number[]) {

View File

@ -16,7 +16,10 @@ import {
executeGA,
allowBlockDonationRequests
} from '../../../redux';
import { AllChallengeNodeType } from '../../../redux/prop-types';
import {
AllChallengeNodeType,
ChallengeFiles
} from '../../../redux/prop-types';
import {
closeModal,
@ -39,14 +42,14 @@ const mapStateToProps = createSelector(
isSignedInSelector,
successMessageSelector,
(
files: Record<string, unknown>,
challengeFiles: ChallengeFiles,
{ title, id }: { title: string; id: string },
completedChallengesIds: string[],
isOpen: boolean,
isSignedIn: boolean,
message: string
) => ({
files,
challengeFiles,
title,
id,
completedChallengesIds,
@ -98,7 +101,7 @@ interface CompletionModalsProps {
completedChallengesIds: string[];
currentBlockIds?: string[];
executeGA: () => void;
files: Record<string, unknown>;
challengeFiles: ChallengeFiles;
id: string;
isOpen: boolean;
isSignedIn: boolean;
@ -133,7 +136,7 @@ export class CompletionModalInner extends Component<
props: CompletionModalsProps,
state: CompletionModalInnerState
): CompletionModalInnerState {
const { files, isOpen } = props;
const { challengeFiles, isOpen } = props;
if (!isOpen) {
return { downloadURL: null, completedPercent: 0 };
}
@ -142,16 +145,14 @@ export class CompletionModalInner extends Component<
URL.revokeObjectURL(downloadURL);
}
let newURL = null;
const fileKeys = Object.keys(files);
if (fileKeys.length) {
const filesForDownload = fileKeys
.map(key => files[key])
if (challengeFiles?.length) {
const filesForDownload = challengeFiles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.reduce<string>((allFiles, currentFile: any) => {
const beforeText = `** start of ${currentFile.path} **\n\n`;
const afterText = `\n\n** end of ${currentFile.path} **\n\n`;
allFiles +=
fileKeys.length > 1
challengeFiles.length > 1
? `${beforeText}${currentFile.contents}${afterText}`
: currentFile.contents;
return allFiles;

View File

@ -14,10 +14,11 @@ import { createSelector } from 'reselect';
import Spacer from '../../../../components/helpers/spacer';
import LearnLayout from '../../../../components/layouts/learn';
import { isSignedInSelector } from '../../../../redux';
import {
ChallengeNodeType,
ChallengeMetaType,
TestType
Test
} from '../../../../redux/prop-types';
import ChallengeDescription from '../../components/Challenge-Description';
import HelpModal from '../../components/HelpModal';
@ -52,7 +53,7 @@ const mapStateToProps = createSelector(
isSignedInSelector,
(
output: string[],
tests: TestType[],
tests: Test[],
isChallengeCompleted: boolean,
isSignedIn: boolean
) => ({
@ -81,7 +82,7 @@ interface BackEndProps {
forumTopicId: number;
id: string;
initConsole: () => void;
initTests: (tests: TestType[]) => void;
initTests: (tests: Test[]) => void;
isChallengeCompleted: boolean;
isSignedIn: boolean;
output: string[];
@ -89,7 +90,7 @@ interface BackEndProps {
challengeMeta: ChallengeMetaType;
};
t: TFunction;
tests: TestType[];
tests: Test[];
title: string;
updateChallengeMeta: (arg0: ChallengeMetaType) => void;
updateSolutionFormValues: () => void;

View File

@ -56,19 +56,25 @@ export const cssToHtml = cond([
[stubTrue, identity]
]);
export function findIndexHtml(files) {
const filtered = files.filter(file => wasHtmlFile(file));
export function findIndexHtml(challengeFiles) {
const filtered = challengeFiles.filter(challengeFile =>
wasHtmlFile(challengeFile)
);
if (filtered.length > 1) {
throw new Error('Too many html blocks in the challenge seed');
}
return filtered[0];
}
function wasHtmlFile(file) {
return file.history[0] === 'index.html';
function wasHtmlFile(challengeFile) {
return challengeFile.history[0] === 'index.html';
}
export function concatHtml({ required = [], template, files = [] } = {}) {
export function concatHtml({
required = [],
template,
challengeFiles = []
} = {}) {
const createBody = template ? _template(template) : defaultTemplate;
const head = required
.map(({ link, src }) => {
@ -87,15 +93,15 @@ A required file can not have both a src and a link: src = ${src}, link = ${link}
})
.reduce((head, element) => head.concat(element));
const indexHtml = findIndexHtml(files);
const indexHtml = findIndexHtml(challengeFiles);
const source = files.reduce((source, file) => {
if (!indexHtml) return source.concat(file.contents, htmlCatch);
const source = challengeFiles.reduce((source, challengeFile) => {
if (!indexHtml) return source.concat(challengeFile.contents, htmlCatch);
if (
indexHtml.importedFiles.includes(file.history[0]) ||
wasHtmlFile(file)
indexHtml.importedFiles.includes(challengeFile.history[0]) ||
wasHtmlFile(challengeFile)
) {
return source.concat(file.contents, htmlCatch);
return source.concat(challengeFile.contents, htmlCatch);
} else {
return source;
}

View File

@ -47,17 +47,22 @@ function getLegacyCode(legacy) {
}, null);
}
function legacyToFile(code, files, key) {
if (isFilesAllPoly(files)) {
return { [key]: setContent(code, files[key]) };
function legacyToFile(code, challengeFiles, fileKey) {
if (isFilesAllPoly(challengeFiles)) {
return {
...setContent(
code,
challengeFiles.find(x => x.fileKey === fileKey)
)
};
}
return false;
}
function isFilesAllPoly(files) {
return Object.keys(files)
.map(key => files[key])
.every(file => isPoly(file));
function isFilesAllPoly(challengeFiles) {
// TODO: figure out how challengeFiles might be null/not have .every as a
// function
return challengeFiles?.every(file => isPoly(file));
}
function clearCodeEpic(action$, state$) {
@ -79,13 +84,15 @@ function saveCodeEpic(action$, state$) {
map(action => {
const state = state$.value;
const { id } = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
const challengeFiles = challengeFilesSelector(state);
try {
store.set(id, files);
// Possible fileType values: indexhtml indexjs indexjsx
// The files Object always has one of these as the first/only attribute
const fileType = Object.keys(files)[0];
if (store.get(id)[fileType].contents !== files[fileType].contents) {
store.set(id, challengeFiles);
const fileKey = challengeFiles[0].fileKey;
if (
store.get(id).find(challengeFile => challengeFile.fileKey === fileKey)
.contents !==
challengeFiles.find(challengeFile => challengeFile.fileKey).contents
) {
throw Error('Failed to save to localStorage');
}
return action;
@ -112,46 +119,37 @@ function loadCodeEpic(action$, state$) {
return action$.pipe(
ofType(actionTypes.challengeMounted),
filter(() => {
const files = challengeFilesSelector(state$.value);
return Object.keys(files).length > 0;
const challengeFiles = challengeFilesSelector(state$.value);
return challengeFiles?.length > 0;
}),
switchMap(({ payload: id }) => {
let finalFiles;
const state = state$.value;
const challenge = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
const fileKeys = Object.keys(files);
const challengeFiles = challengeFilesSelector(state);
const fileKeys = challengeFiles.map(x => x.fileKey);
const invalidForLegacy = fileKeys.length > 1;
const { title: legacyKey } = challenge;
const codeFound = getCode(id);
if (codeFound && isFilesAllPoly(codeFound)) {
finalFiles = {
...fileKeys
.map(key => files[key])
.reduce(
(files, file) => ({
...files,
[file.key]: {
...file,
contents: codeFound[file.key]
? codeFound[file.key].contents
: file.contents,
editableContents: codeFound[file.key]
? codeFound[file.key].editableContents
: file.editableContents,
editableRegionBoundaries: codeFound[file.key]
? codeFound[file.key].editableRegionBoundaries
: file.editableRegionBoundaries
finalFiles = challengeFiles.reduce((challengeFiles, challengeFile) => {
const foundChallengeFile = codeFound.find(
x => x.fileKey === challengeFile.fileKey
);
const isCodeFound = Object.keys(foundChallengeFile).length > 0;
return [
...challengeFiles,
{
...challengeFile,
...(isCodeFound ? foundChallengeFile : {})
}
}),
{}
)
};
];
}, []);
} else {
const legacyCode = getLegacyCode(legacyKey);
if (legacyCode && !invalidForLegacy) {
finalFiles = legacyToFile(legacyCode, files, fileKeys[0]);
finalFiles = legacyToFile(legacyCode, challengeFiles, fileKeys[0]);
}
}
if (finalFiles) {

View File

@ -63,11 +63,11 @@ function submitModern(type, state) {
if (type === actionTypes.submitChallenge) {
const { id } = challengeMetaSelector(state);
const files = challengeFilesSelector(state);
const challengeFiles = challengeFilesSelector(state);
const { username } = userSelector(state);
const challengeInfo = {
id,
files
challengeFiles
};
const update = {
endpoint: '/modern-challenge-completed',

View File

@ -14,16 +14,17 @@ import { actionTypes } from './action-types';
const { forumLocation } = envData;
function filesToMarkdown(files = {}) {
const moreThenOneFile = Object.keys(files).length > 1;
return Object.keys(files).reduce((fileString, key) => {
const file = files[key];
if (!file) {
function filesToMarkdown(challengeFiles = {}) {
const moreThanOneFile = challengeFiles?.length > 1;
return challengeFiles.reduce((fileString, challengeFile) => {
if (!challengeFile) {
return fileString;
}
const fileName = moreThenOneFile ? `\\ file: ${file.contents}` : '';
const fileType = file.ext;
return `${fileString}\`\`\`${fileType}\n${fileName}\n${file.contents}\n\`\`\`\n\n`;
const fileName = moreThanOneFile
? `\\ file: ${challengeFile.contents}`
: '';
const fileType = challengeFile.ext;
return `${fileString}\`\`\`${fileType}\n${fileName}\n${challengeFile.contents}\n\`\`\`\n\n`;
}, '\n');
}
@ -32,7 +33,7 @@ function createQuestionEpic(action$, state$, { window }) {
ofType(actionTypes.createQuestion),
tap(() => {
const state = state$.value;
const files = challengeFilesSelector(state);
const challengeFiles = challengeFilesSelector(state);
const { title: challengeTitle, helpCategory } =
challengeMetaSelector(state);
const {
@ -64,7 +65,7 @@ function createQuestionEpic(action$, state$, { window }) {
${
projectFormValues
?.map(([key, val]) => `${key}: ${transformEditorLink(val)}\n`)
?.join('') || filesToMarkdown(files)
?.join('') || filesToMarkdown(challengeFiles)
}\n\n
${endingText}`);

View File

@ -19,7 +19,7 @@ export { ns };
const initialState = {
canFocusEditor: true,
visibleEditors: {},
challengeFiles: {},
challengeFiles: [],
challengeMeta: {
superBlock: '',
block: '',
@ -60,24 +60,21 @@ export const sagas = [
export const createFiles = createAction(
actionTypes.createFiles,
challengeFiles =>
Object.keys(challengeFiles)
.filter(key => challengeFiles[key])
.map(key => challengeFiles[key])
.reduce(
(challengeFiles, file) => ({
challengeFiles.reduce((challengeFiles, challengeFile) => {
return [
...challengeFiles,
[file.key]: {
...createPoly(file),
seed: file.contents.slice(),
{
...createPoly(challengeFile),
seed: challengeFile.contents.slice(),
editableContents: getLines(
file.contents,
file.editableRegionBoundaries
challengeFile.contents,
challengeFile.editableRegionBoundaries
),
seedEditableRegionBoundaries: file.editableRegionBoundaries.slice()
seedEditableRegionBoundaries:
challengeFile.editableRegionBoundaries.slice()
}
}),
{}
)
];
}, [])
);
export const createQuestion = createAction(actionTypes.createQuestion);
@ -165,7 +162,7 @@ export const challengeDataSelector = state => {
) {
challengeData = {
...challengeData,
files: challengeFilesSelector(state)
challengeFiles: challengeFilesSelector(state)
};
} else if (challengeType === challengeTypes.backend) {
const { solution: url = {} } = projectFormValuesSelector(state);
@ -196,7 +193,7 @@ export const challengeDataSelector = state => {
const { required = [], template = '' } = challengeMetaSelector(state);
challengeData = {
...challengeData,
files: challengeFilesSelector(state),
challengeFiles: challengeFilesSelector(state),
required,
template
};
@ -216,19 +213,21 @@ export const reducer = handleActions(
}),
[actionTypes.updateFile]: (
state,
{ payload: { key, editorValue, editableRegionBoundaries } }
) => ({
{ payload: { fileKey, editorValue, editableRegionBoundaries } }
) => {
return {
...state,
challengeFiles: {
...state.challengeFiles,
[key]: {
...state.challengeFiles[key],
challengeFiles: [
...state.challengeFiles.filter(x => x.fileKey !== fileKey),
{
...state.challengeFiles.find(x => x.fileKey === fileKey),
contents: editorValue,
editableContents: getLines(editorValue, editableRegionBoundaries),
editableRegionBoundaries
}
}
}),
]
};
},
[actionTypes.storedCodeFound]: (state, { payload }) => ({
...state,
challengeFiles: payload
@ -268,35 +267,33 @@ export const reducer = handleActions(
...state,
challengeMeta: { ...payload }
}),
[actionTypes.resetChallenge]: state => ({
...state,
currentTab: 2,
challengeFiles: {
...Object.keys(state.challengeFiles)
.map(key => state.challengeFiles[key])
.reduce(
(files, file) => ({
...files,
[file.key]: {
...file,
contents: file.seed.slice(),
[actionTypes.resetChallenge]: state => {
const challengeFilesReset = [
...state.challengeFiles.reduce(
(challengeFiles, challengeFile) => ({
...challengeFiles,
...challengeFile,
contents: challengeFile.seed.slice(),
editableContents: getLines(
file.seed,
file.seedEditableRegionBoundaries
challengeFile.seed,
challengeFile.seedEditableRegionBoundaries
),
editableRegionBoundaries: file.seedEditableRegionBoundaries
}
editableRegionBoundaries: challengeFile.seedEditableRegionBoundaries
}),
{}
)
},
];
return {
...state,
currentTab: 2,
challengeFiles: challengeFilesReset,
challengeTests: state.challengeTests.map(({ text, testString }) => ({
text,
testString
})),
consoleOut: []
}),
};
},
[actionTypes.updateSolutionFormValues]: (state, { payload }) => ({
...state,
projectFormValues: payload

View File

@ -49,7 +49,7 @@ const applyFunction = fn =>
const composeFunctions = (...fns) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
function buildSourceMap(files) {
function buildSourceMap(challengeFiles) {
// TODO: concatenating the source/contents is a quick hack for multi-file
// editing. It is used because all the files (js, html and css) end up with
// the same name 'index'. This made the last file the only file to appear in
@ -57,22 +57,26 @@ function buildSourceMap(files) {
// A better solution is to store and handle them separately. Perhaps never
// setting the name to 'index'. Use 'contents' instead?
// TODO: is file.source ever defined?
return files.reduce(
(sources, file) => {
sources[file.name] += file.source || file.contents;
sources.editableContents += file.editableContents || '';
const source = challengeFiles.reduce(
(sources, challengeFile) => {
sources[challengeFile.name] +=
challengeFile.source || challengeFile.contents;
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{ index: '', editableContents: '' }
);
return source;
}
function checkFilesErrors(files) {
const errors = files.filter(({ error }) => error).map(({ error }) => error);
function checkFilesErrors(challengeFiles) {
const errors = challengeFiles
.filter(({ error }) => error)
.map(({ error }) => error);
if (errors.length) {
throw errors;
}
return files;
return challengeFiles;
}
const buildFunctions = {
@ -140,41 +144,48 @@ async function getDOMTestRunner(buildData, { proxyLogger }, document) {
runTestInTestFrame(document, testString, testTimeout);
}
export function buildDOMChallenge({ files, required = [], template = '' }) {
export function buildDOMChallenge({
challengeFiles,
required = [],
template = ''
}) {
const finalRequires = [...globalRequires, ...required, ...frameRunner];
const loadEnzyme = Object.keys(files).some(key => files[key].ext === 'jsx');
const loadEnzyme = challengeFiles.some(
challengeFile => challengeFile.ext === 'jsx'
);
const toHtml = [jsToHtml, cssToHtml];
const pipeLine = composeFunctions(...getTransformers(), ...toHtml);
const finalFiles = Object.keys(files)
.map(key => files[key])
.map(pipeLine);
const finalFiles = challengeFiles.map(pipeLine);
return Promise.all(finalFiles)
.then(checkFilesErrors)
.then(files => ({
.then(challengeFiles => ({
challengeType: challengeTypes.html,
build: concatHtml({ required: finalRequires, template, files }),
sources: buildSourceMap(files),
build: concatHtml({ required: finalRequires, template, challengeFiles }),
sources: buildSourceMap(challengeFiles),
loadEnzyme
}));
}
export function buildJSChallenge({ files }, options) {
export function buildJSChallenge({ challengeFiles }, options) {
const pipeLine = composeFunctions(...getTransformers(options));
const finalFiles = Object.keys(files)
.map(key => files[key])
.map(pipeLine);
const finalFiles = challengeFiles.map(pipeLine);
return Promise.all(finalFiles)
.then(checkFilesErrors)
.then(files => ({
.then(challengeFiles => ({
challengeType: challengeTypes.js,
build: files
build: challengeFiles
.reduce(
(body, file) => [...body, file.head, file.contents, file.tail],
(body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[]
)
.join('\n'),
sources: buildSourceMap(files)
sources: buildSourceMap(challengeFiles)
}));
}

View File

@ -4,11 +4,11 @@ import { toSortedArray } from '../../../../../utils/sort-files';
export function getTargetEditor(challengeFiles) {
if (isEmpty(challengeFiles)) return null;
else {
let targetEditor = Object.values(challengeFiles).find(
let targetEditor = challengeFiles.find(
({ editableRegionBoundaries }) => !isEmpty(editableRegionBoundaries)
)?.key;
)?.fileKey;
// fallback for when there is no editable region.
return targetEditor || toSortedArray(challengeFiles)[0].key;
return targetEditor || toSortedArray(challengeFiles)[0].fileKey;
}
}

View File

@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const util = require('util');
const yaml = require('js-yaml');
const { findIndex, reduce, toString } = require('lodash');
const { findIndex } = require('lodash');
const readDirP = require('readdirp');
const { helpCategoryMap } = require('../client/utils/challenge-types');
const { showUpcomingChanges } = require('../config/env.json');
@ -306,45 +306,22 @@ ${getFullPath('english')}
return prepareChallenge(challenge);
}
// TODO: tests and more descriptive name.
function filesToObject(files) {
return reduce(
files,
(map, file) => {
map[file.key] = {
...file,
head: arrToString(file.head),
contents: arrToString(file.contents),
tail: arrToString(file.tail)
};
return map;
},
{}
);
}
// gets the challenge ready for sourcing into Gatsby
function prepareChallenge(challenge) {
if (challenge.files) {
challenge.files = filesToObject(challenge.files);
challenge.files = Object.keys(challenge.files)
.filter(key => challenge.files[key])
.map(key => challenge.files[key])
.reduce(
(files, file) => ({
...files,
[file.key]: {
...createPoly(file),
seed: file.contents.slice(0)
if (challenge.challengeFiles) {
challenge.challengeFiles = challenge.challengeFiles.reduce(
(challengeFiles, challengeFile) => {
return [
...challengeFiles,
{
...createPoly(challengeFile),
seed: challengeFile.contents.slice(0)
}
}),
{}
];
},
[]
);
}
if (challenge.solutionFiles) {
challenge.solutionFiles = filesToObject(challenge.solutionFiles);
}
return challenge;
}
@ -381,10 +358,6 @@ function getBlockNameFromPath(filePath) {
return block;
}
function arrToString(arr) {
return Array.isArray(arr) ? arr.join('\n') : toString(arr);
}
exports.hasEnglishSource = hasEnglishSource;
exports.parseTranslation = parseTranslation;
exports.createChallenge = createChallenge;

View File

@ -6,7 +6,7 @@ const { challengeTypes } = require('../../client/utils/challenge-types');
const slugRE = new RegExp('^[a-z0-9-]+$');
const fileJoi = Joi.object().keys({
key: Joi.string(),
fileKey: Joi.string(),
ext: Joi.string(),
name: Joi.string(),
editableRegionBoundaries: [Joi.array().items(Joi.number())],
@ -37,12 +37,7 @@ const schema = Joi.object()
then: Joi.string().allow(''),
otherwise: Joi.string().required()
}),
files: Joi.object().keys({
indexcss: fileJoi,
indexhtml: fileJoi,
indexjs: fileJoi,
indexjsx: fileJoi
}),
challengeFiles: Joi.array().items(fileJoi),
guideUrl: Joi.string().uri({ scheme: 'https' }),
helpCategory: Joi.valid(
'JavaScript',
@ -76,15 +71,7 @@ const schema = Joi.object()
crossDomain: Joi.bool()
})
),
solutions: Joi.array().items(
Joi.object().keys({
indexcss: fileJoi,
indexhtml: fileJoi,
indexjs: fileJoi,
indexjsx: fileJoi,
indexpy: fileJoi
})
),
solutions: Joi.array().items(Joi.array().items(fileJoi)),
superBlock: Joi.string().regex(slugRE),
superOrder: Joi.number(),
suborder: Joi.number(),

View File

@ -307,16 +307,16 @@ function populateTestsForLang({ lang, challenges, meta }) {
return;
}
// If no .files, then no seed:
if (!challenge.files) return;
// If no .challengeFiles, then no seed:
if (!challenge.challengeFiles) return;
// - None of the translatable comments should appear in the
// translations. While this is a crude check, no challenges
// currently have the text of a comment elsewhere. If that happens
// we can handle that challenge separately.
TRANSLATABLE_COMMENTS.forEach(comment => {
Object.values(challenge.files).forEach(file => {
if (file.contents.includes(comment))
challenge.challengeFiles.forEach(challengeFile => {
if (challengeFile.contents.includes(comment))
throw Error(
`English comment '${comment}' should be replaced with its translation`
);
@ -325,14 +325,16 @@ function populateTestsForLang({ lang, challenges, meta }) {
// - None of the translated comment texts should appear *outside* a
// comment
Object.values(challenge.files).forEach(file => {
challenge.challengeFiles.forEach(challengeFile => {
let comments = {};
// We get all the actual comments using the appropriate parsers
if (file.ext === 'html') {
if (challengeFile.ext === 'html') {
const commentTypes = ['css', 'html', 'scriptJs'];
for (let type of commentTypes) {
const newComments = commentExtractors[type](file.contents);
const newComments = commentExtractors[type](
challengeFile.contents
);
for (const [key, value] of Object.entries(newComments)) {
comments[key] = comments[key]
? comments[key] + value
@ -340,7 +342,9 @@ function populateTestsForLang({ lang, challenges, meta }) {
}
}
} else {
comments = commentExtractors[file.ext](file.contents);
comments = commentExtractors[challengeFile.ext](
challengeFile.contents
);
}
// Then we compare the number of times each comment appears in the
@ -409,7 +413,7 @@ ${inspect(commentMap)}
try {
testRunner = await createTestRunner(
challenge,
'',
[],
buildChallenge
);
} catch {
@ -448,12 +452,13 @@ ${inspect(commentMap)}
// TODO: can this be dried out, ideally by removing the redux
// handler?
if (nextChallenge) {
const solutionFiles = cloneDeep(nextChallenge.files);
Object.keys(solutionFiles).forEach(key => {
const file = solutionFiles[key];
file.editableContents = getLines(
file.contents,
challenge.files[key].editableRegionBoundaries
const solutionFiles = cloneDeep(nextChallenge.challengeFiles);
solutionFiles.forEach(challengeFile => {
challengeFile.editableContents = getLines(
challengeFile.contents,
challenge.challengeFiles.find(
x => x.fileKey === challengeFile.fileKey
).editableRegionBoundaries
);
});
solutions = [solutionFiles];
@ -470,7 +475,9 @@ ${inspect(commentMap)}
const filteredSolutions = solutionsAsArrays.filter(solution => {
return !isEmpty(
solution.filter(file => !noSolution.test(file.contents))
solution.filter(
challengeFile => !noSolution.test(challengeFile.contents)
)
);
});
@ -505,21 +512,23 @@ ${inspect(commentMap)}
async function createTestRunner(
challenge,
solution,
solutionFiles,
buildChallenge,
solutionFromNext
) {
const { required = [], template, removeComments } = challenge;
// we should avoid modifying challenge, as it gets reused:
const files = cloneDeep(challenge.files);
Object.keys(solution).forEach(key => {
files[key].contents = solution[key].contents;
files[key].editableContents = solution[key].editableContents;
const challengeFiles = cloneDeep(challenge.challengeFiles);
solutionFiles.forEach(solutionFile => {
const challengeFile = challengeFiles.find(
x => x.fileKey === solutionFile.fileKey
);
challengeFile.contents = solutionFile.contents;
challengeFile.editableContents = solutionFile.editableContents;
});
const { build, sources, loadEnzyme } = await buildChallenge({
files,
challengeFiles,
required,
template
});

View File

@ -0,0 +1,37 @@
/* global cy */
const selectors = {
defaultOutput: '.output-text',
editor: '.react-monaco-editor-container'
};
const location =
'/learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements';
describe('Challenge with editor', function () {
before(() => {
cy.visit(location);
});
it('renders seed code without localStorage', () => {
const editorContents = `<h1>Hello</h1>`;
cy.get(selectors.editor).as('editor').contains(editorContents);
cy.get('@editor').click().focused().type(`{movetoend}<h1>Hello World</h1>`);
cy.reload();
cy.get('@editor', { timeout: 10000 }).contains(editorContents);
});
it('renders code from localStorage after "Ctrl + S"', () => {
const editorContents = `<h1>Hello</h1>`;
cy.get(selectors.editor).as('editor').contains(editorContents);
cy.get('@editor')
.click()
.focused()
.type(`{movetoend}<h1>Hello World</h1>{ctrl+s}`);
cy.contains("Saved! Your code was saved to your browser's local storage.");
cy.reload();
cy.get('@editor', { timeout: 10000 }).contains(
'<h1>Hello</h1><h1>Hello World</h1>'
);
});
});

View File

@ -13,7 +13,7 @@ a container directive
:::</p>
</section>",
"solutions": Array [
Object {},
Array [],
],
"tests": Array [],
}
@ -44,7 +44,7 @@ Object {
</code></pre>",
},
"solutions": Array [
Object {},
Array [],
],
"tests": Array [],
}
@ -52,38 +52,33 @@ Object {
exports[`challenge parser should import md from other files 1`] = `
Object {
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';
for (let index = 0; index < array.length; index++) {
const element = array[index];
@ -91,20 +86,25 @@ for (let index = 0; index < array.length; index++) {
}",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "custom-name",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0
</code></pre>
</section>",
"solutions": Array [
Object {},
Array [],
],
"tests": Array [
Object {
@ -121,6 +121,43 @@ for (let index = 0; index < array.length; index++) {
exports[`challenge parser should not mix other YAML with the frontmatter 1`] = `
Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
],
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
@ -130,50 +167,13 @@ Object {
anothersubkey: another value
</code></pre>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
"instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0
</code></pre>
</section>",
"solutions": Array [
Object {},
Array [],
],
"tests": Array [
Object {
@ -190,42 +190,8 @@ Object {
exports[`challenge parser should parse a more realistic md file 1`] = `
Object {
"description": "<section id=\\"description\\">
<p>When you add a lower rank heading element to the page, it's implied that you're starting a new subsection.</p>
<p>After the last <code>h2</code> element of the second <code>section</code> element, add an <code>h3</code> element with the text <code>Things cats love:</code>.</p>
<blockquote>
<p>Some text in a blockquote</p>
<p>
Some text in a blockquote, with <code>code</code>
</p>
</blockquote>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: white;
}
h1 {
font-size: 20px;
}
a {
color: green;
}",
"editableRegionBoundaries": Array [
7,
9,
],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
<h1>CatPhotoApp</h1>
@ -256,31 +222,13 @@ a {
23,
],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "html-key",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"head": " // this runs before the user's code is evaluated.",
"id": "final-key",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
"instructions": "<section id=\\"instructions\\">
<p>Do something with the <code>code</code>.</p>
<p>To test that adjacent tags are handled correctly:</p>
<p>a bit of <code>code</code> <tag>with more after a space</tag> and another pair of <strong>elements</strong> <em>with a space</em></p>
</section>",
"solutions": Array [
Object {
"indexcss": Object {
"contents": "body {
background: white;
}
@ -293,14 +241,46 @@ h1 {
a {
color: green;
}",
"editableRegionBoundaries": Array [
7,
9,
],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": " // this runs before the user's code is evaluated.",
"id": "final-key",
"name": "index",
"tail": "",
},
],
"description": "<section id=\\"description\\">
<p>When you add a lower rank heading element to the page, it's implied that you're starting a new subsection.</p>
<p>After the last <code>h2</code> element of the second <code>section</code> element, add an <code>h3</code> element with the text <code>Things cats love:</code>.</p>
<blockquote>
<p>Some text in a blockquote</p>
<p>
Some text in a blockquote, with <code>code</code>
</p>
</blockquote>
</section>",
"instructions": "<section id=\\"instructions\\">
<p>Do something with the <code>code</code>.</p>
<p>To test that adjacent tags are handled correctly:</p>
<p>a bit of <code>code</code> <tag>with more after a space</tag> and another pair of <strong>elements</strong> <em>with a space</em></p>
</section>",
"solutions": Array [
Array [
Object {
"contents": "<html>
<body>
<h1>CatPhotoApp</h1>
@ -327,22 +307,42 @@ a {
</body>
</html>",
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "html-key",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: white;
}
h1 {
font-size: 20px;
}
a {
color: green;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "final-key",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
],
"tests": Array [
Object {
@ -385,89 +385,89 @@ assert(
exports[`challenge parser should parse a simple md file 1`] = `
Object {
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0
</code></pre>
</section>",
"solutions": Array [
Array [
Object {
"indexcss": Object {
"contents": "body {
background: white;
}",
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html>
<body>
</body>
</html>",
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "html-key",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: white;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';
\`\`",
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
],
"tests": Array [
Object {
@ -491,54 +491,54 @@ if(let x of xs) {
exports[`challenge parser should parse frontmatter 1`] = `
Object {
"challengeType": 0,
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
"challengeType": 0,
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"forumTopicId": 18276,
"id": "bd7123c8c441eddfaeb5bdef",
"isHidden": false,
"solutions": Array [
Object {},
Array [],
],
"tests": Array [
Object {
@ -557,6 +557,43 @@ Object {
exports[`challenge parser should parse gfm strikethrough and frontmatter 1`] = `
Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
],
"description": "<section id=\\"description\\">
<p>Paragraph 1 <del>Strikethrough text</del>. https://should.not.be.autolinked</p>
<pre><code class=\\"language-html\\">code example
@ -576,84 +613,47 @@ Object {
</tbody>
</table>
</section>",
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
"instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0
</code></pre>
</section>",
"solutions": Array [
Array [
Object {
"indexcss": Object {
"contents": "body {
background: white;
}",
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html>
<body>
</body>
</html>",
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "html-key",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: white;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';
\`\`",
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
],
"tests": Array [
Object {

View File

@ -2,42 +2,42 @@
exports[`add-seed plugin should have an output to match the snapshot 1`] = `
Object {
"files": Object {
"indexcss": Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"challengeFiles": Array [
Object {
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": Array [],
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';",
"editableRegionBoundaries": Array [],
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
}
`;

View File

@ -3,41 +3,41 @@
exports[`add solution plugin should have an output to match the snapshot 1`] = `
Object {
"solutions": Array [
Array [
Object {
"indexcss": Object {
"contents": "body {
background: white;
}",
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html>
<body>
</body>
</html>",
"ext": "html",
"fileKey": "indexhtml",
"head": "",
"id": "html-key",
"key": "indexhtml",
"name": "index",
"tail": "",
},
"indexjs": Object {
Object {
"contents": "body {
background: white;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';
\`\`",
"ext": "js",
"fileKey": "indexjs",
"head": "",
"id": "",
"key": "indexjs",
"name": "index",
"tail": "",
},
},
],
],
}
`;

View File

@ -7,8 +7,8 @@ const { getFileVisitor } = require('./utils/get-file-visitor');
const editableRegionMarker = '--fcc-editable-region--';
function findRegionMarkers(file) {
const lines = file.contents.split('\n');
function findRegionMarkers(challengeFile) {
const lines = challengeFile.contents.split('\n');
const editableLines = lines
.map((line, id) => (line.trim() === editableRegionMarker ? id : -1))
.filter(id => id >= 0);
@ -55,26 +55,33 @@ function addSeeds() {
visitForContents(contentsTree);
visitForHead(headTree);
visitForTail(tailTree);
const seedVals = Object.values(seeds);
file.data = {
...file.data,
files: seeds
challengeFiles: seedVals
};
// process region markers - remove them from content and add them to data
Object.keys(seeds).forEach(key => {
const fileData = seeds[key];
const editRegionMarkers = findRegionMarkers(fileData);
const challengeFiles = Object.values(seeds).map(data => {
const seed = { ...data };
const editRegionMarkers = findRegionMarkers(seed);
if (editRegionMarkers) {
fileData.contents = removeLines(fileData.contents, editRegionMarkers);
seed.contents = removeLines(seed.contents, editRegionMarkers);
if (editRegionMarkers[1] <= editRegionMarkers[0]) {
throw Error('Editable region must be non zero');
}
fileData.editableRegionBoundaries = editRegionMarkers;
seed.editableRegionBoundaries = editRegionMarkers;
} else {
fileData.editableRegionBoundaries = [];
seed.editableRegionBoundaries = [];
}
return seed;
});
file.data = {
...file.data,
challengeFiles
};
}
return transformer;

View File

@ -1,4 +1,3 @@
const { isObject } = require('lodash');
const isArray = require('lodash/isArray');
const adjacentKeysAST = require('../__fixtures__/ast-adjacent-keys.json');
@ -32,26 +31,26 @@ describe('add-seed plugin', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `files` property to `file.data`', () => {
it('adds a `challengeFiles` property to `file.data`', () => {
plugin(simpleAST, file);
expect('files' in file.data).toBe(true);
expect('challengeFiles' in file.data).toBe(true);
});
it('ensures that the `files` property is an object', () => {
it('ensures that the `challengeFiles` property is an array', () => {
plugin(simpleAST, file);
expect(isObject(file.data.files)).toBe(true);
expect(isArray(file.data.challengeFiles)).toBe(true);
});
it('adds test objects to the files array following a schema', () => {
it('adds test objects to the challengeFiles array following a schema', () => {
expect.assertions(17);
plugin(simpleAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const testObject = files.indexjs;
const testObject = challengeFiles.find(x => x.fileKey === 'indexjs');
expect(Object.keys(testObject).length).toEqual(8);
expect(testObject).toHaveProperty('key');
expect(typeof testObject['key']).toBe('string');
expect(testObject).toHaveProperty('fileKey');
expect(typeof testObject['fileKey']).toBe('string');
expect(testObject).toHaveProperty('ext');
expect(typeof testObject['ext']).toBe('string');
expect(testObject).toHaveProperty('name');
@ -69,33 +68,32 @@ describe('add-seed plugin', () => {
});
it('parses seeds without ids', () => {
expect.assertions(6);
expect.assertions(3);
plugin(simpleAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjs, indexhtml, indexcss } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexjs.contents).toBe(`var x = 'y';`);
expect(indexjs.key).toBe(`indexjs`);
expect(indexhtml.contents).toBe(`<html>
<body>
</body>
</html>`);
expect(indexhtml.key).toBe(`indexhtml`);
expect(indexcss.contents).toBe(`body {
background: green;
}`);
expect(indexcss.key).toBe(`indexcss`);
});
it('removes region markers from contents', () => {
expect.assertions(2);
plugin(withEditableAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexcss } = files;
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexcss.contents).not.toMatch('--fcc-editable-region--');
expect(indexcss.editableRegionBoundaries).toEqual([1, 4]);
@ -107,9 +105,11 @@ describe('add-seed plugin', () => {
expect.assertions(3);
plugin(withSeedKeysAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexhtml, indexcss, indexjs } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexhtml.id).toBe('');
expect(indexcss.id).toBe('key-for-css');
@ -138,9 +138,11 @@ describe('add-seed plugin', () => {
expect.assertions(3);
plugin(withBeforeAfterAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjs, indexhtml, indexcss } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexjs.head).toBe('');
expect(indexhtml.head).toBe(`<!-- comment -->`);
@ -153,9 +155,11 @@ describe('add-seed plugin', () => {
expect.assertions(3);
plugin(withBeforeAfterAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjs, indexhtml, indexcss } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexjs.tail).toBe(`function teardown(params) {
// after
@ -188,9 +192,11 @@ describe('add-seed plugin', () => {
expect.assertions(6);
plugin(emptyBeforeAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjs, indexhtml, indexcss } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexjs.head).toBe('');
expect(indexjs.tail).toBe('function teardown(params) {\n // after\n}');
@ -204,9 +210,11 @@ describe('add-seed plugin', () => {
expect.assertions(6);
plugin(emptyAfterAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjs, indexhtml, indexcss } = files;
const indexjs = challengeFiles.find(x => x.fileKey === 'indexjs');
const indexhtml = challengeFiles.find(x => x.fileKey === 'indexhtml');
const indexcss = challengeFiles.find(x => x.fileKey === 'indexcss');
expect(indexjs.head).toBe('');
expect(indexjs.tail).toBe('');
@ -234,9 +242,9 @@ describe('add-seed plugin', () => {
expect.assertions(4);
plugin(jsxSeedAST, file);
const {
data: { files }
data: { challengeFiles }
} = file;
const { indexjsx } = files;
const indexjsx = challengeFiles.find(x => x.fileKey === 'indexjsx');
expect(indexjsx.head).toBe(`function setup() {}`);
expect(indexjsx.tail).toBe(`function teardown(params) {
@ -248,7 +256,7 @@ describe('add-seed plugin', () => {
const Button = () => {
return <button> {/* another comment! */} text </button>;
};`);
expect(indexjsx.key).toBe(`indexjsx`);
expect(indexjsx.fileKey).toBe(`indexjsx`);
});
it('combines all the code of a specific language into a single file', () => {

View File

@ -30,7 +30,7 @@ function createPlugin() {
);
visitForContents(solutionTree);
solutions.push(solution);
solutions.push(Object.values(solution));
});
file.data = {

View File

@ -33,16 +33,18 @@ describe('add solution plugin', () => {
expect(file.data.solutions.every(el => isObject(el))).toBe(true);
});
it('adds solution objects to the files array following a schema', () => {
it('adds solution objects to the challengeFiles array following a schema', () => {
expect.assertions(15);
plugin(mockAST, file);
const {
data: { solutions }
} = file;
const testObject = solutions[0].indexjs;
const testObject = solutions[0].find(
solution => solution.fileKey === 'indexjs'
);
expect(Object.keys(testObject).length).toEqual(7);
expect(testObject).toHaveProperty('key');
expect(typeof testObject['key']).toBe('string');
expect(testObject).toHaveProperty('fileKey');
expect(typeof testObject['fileKey']).toBe('string');
expect(testObject).toHaveProperty('ext');
expect(typeof testObject['ext']).toBe('string');
expect(testObject).toHaveProperty('name');
@ -64,16 +66,24 @@ describe('add solution plugin', () => {
data: { solutions }
} = file;
expect(solutions.length).toBe(3);
expect(solutions[0].indexjs.contents).toBe("var x = 'y';");
expect(solutions[1].indexhtml.contents).toBe(`<html>
expect(
solutions[0].find(solution => solution.fileKey === 'indexjs').contents
).toBe("var x = 'y';");
expect(
solutions[1].find(solution => solution.fileKey === 'indexhtml').contents
).toBe(`<html>
<body>
solution number two
</body>
</html>`);
expect(solutions[1].indexcss.contents).toBe(`body {
expect(
solutions[1].find(solution => solution.fileKey === 'indexcss').contents
).toBe(`body {
background: white;
}`);
expect(solutions[2].indexjs.contents).toBe("var x = 'y3';");
expect(
solutions[2].find(solution => solution.fileKey === 'indexjs').contents
).toBe("var x = 'y3';");
});
it('should reject solutions with editable region markers', () => {

View File

@ -12,7 +12,7 @@ const supportedLanguages = ['js', 'css', 'html', 'jsx', 'py'];
function defaultFile(lang, id) {
return {
key: `index${lang}`,
fileKey: `index${lang}`,
ext: lang,
name: 'index',
contents: '',
@ -43,21 +43,21 @@ function codeToData(node, seeds, seedKey, validate) {
Please use one of js, css, html, jsx or py
`);
const key = `index${lang}`;
const id = seeds[key] ? seeds[key].id : '';
const fileKey = `index${lang}`;
const id = seeds[fileKey] ? seeds[fileKey].id : '';
// the contents will be missing if there is an id preceding this code
// block.
if (!seeds[key]) {
seeds[key] = defaultFile(lang, id);
if (!seeds[fileKey]) {
seeds[fileKey] = defaultFile(lang, id);
}
if (isEmpty(node.value) && seedKey !== 'contents') {
const section = keyToSection[seedKey];
throw Error(`Empty code block in --${section}-- section`);
}
seeds[key][seedKey] = isEmpty(seeds[key][seedKey])
seeds[fileKey][seedKey] = isEmpty(seeds[fileKey][seedKey])
? node.value
: seeds[key][seedKey] + '\n' + node.value;
: seeds[fileKey][seedKey] + '\n' + node.value;
}
function idToData(node, index, parent, seeds) {
@ -73,9 +73,9 @@ function idToData(node, index, parent, seeds) {
}
const codeNode = parent.children[index + 1];
if (codeNode && is(codeNode, 'code')) {
const key = `index${codeNode.lang}`;
if (seeds[key]) throw Error('::id{#id}s must come before code blocks');
seeds[key] = defaultFile(codeNode.lang, id);
const fileKey = `index${codeNode.lang}`;
if (seeds[fileKey]) throw Error('::id{#id}s must come before code blocks');
seeds[fileKey] = defaultFile(codeNode.lang, id);
} else {
throw Error('::id{#id}s must come before code blocks');
}

View File

@ -17,7 +17,7 @@ const ENGLISH_CHALLENGE_NO_FILES = {
solutions: ['solution html string'],
description: 'description html string',
instructions: 'instructions html string',
files: []
challengeFiles: []
};
exports.ENGLISH_CHALLENGE_NO_FILES = ENGLISH_CHALLENGE_NO_FILES;

View File

@ -17,19 +17,19 @@ exports.translateComments = (text, lang, dict, codeLang) => {
exports.translateCommentsInChallenge = (challenge, lang, dict) => {
const challClone = cloneDeep(challenge);
if (!challClone.files) {
if (!challClone.challengeFiles) {
console.warn(`Challenge ${challClone.title} has no seed to translate`);
} else {
Object.keys(challClone.files).forEach(key => {
if (challClone.files[key].contents) {
challClone.challengeFiles.forEach(challengeFile => {
if (challengeFile.contents) {
let { text, commentCounts } = this.translateComments(
challenge.files[key].contents,
challengeFile.contents,
lang,
dict,
challClone.files[key].ext
challengeFile.ext
);
challClone.__commentCounts = commentCounts;
challClone.files[key].contents = text;
challengeFile.contents = text;
}
});
}

View File

@ -1,50 +1,50 @@
exports.challengeFiles = {
indexcss: {
exports.challengeFiles = [
{
contents: 'some css',
error: null,
ext: 'css',
head: '',
history: ['index.css'],
key: 'indexcss',
fileKey: 'indexcss',
name: 'index',
path: 'index.css',
seed: 'some css',
tail: ''
},
indexhtml: {
{
contents: 'some html',
error: null,
ext: 'html',
head: '',
history: ['index.html'],
key: 'indexhtml',
fileKey: 'indexhtml',
name: 'index',
path: 'index.html',
seed: 'some html',
tail: ''
},
indexjs: {
{
contents: 'some js',
error: null,
ext: 'js',
head: '',
history: ['index.js'],
key: 'indexjs',
fileKey: 'indexjs',
name: 'index',
path: 'index.js',
seed: 'some js',
tail: ''
},
indexjsx: {
{
contents: 'some jsx',
error: null,
ext: 'jsx',
head: '',
history: ['index.jsx'],
key: 'indexjsx',
fileKey: 'indexjsx',
name: 'index',
path: 'index.jsx',
seed: 'some jsx',
tail: ''
}
};
];

View File

@ -37,7 +37,7 @@ function createPoly({ name, ext, contents, history, ...rest } = {}) {
name,
ext,
path: name + '.' + ext,
key: name + ext,
fileKey: name + ext,
contents,
error: null
};
@ -81,7 +81,7 @@ function setExt(ext, poly) {
...poly,
ext,
path: poly.name + '.' + ext,
key: poly.name + ext
fileKey: poly.name + ext
};
newPoly.history = [...poly.history, newPoly.path];
return newPoly;

View File

@ -1,5 +1,5 @@
exports.toSortedArray = function toSortedArray(challengeFiles) {
const xs = Object.values(challengeFiles);
const xs = challengeFiles;
// TODO: refactor this to use an ext array ['html', 'js', 'css'] and loop over
// that.
xs.sort((a, b) => {

View File

@ -9,14 +9,14 @@ describe('sort-files', () => {
});
it('should not modify the challenges', () => {
const sorted = toSortedArray(challengeFiles);
const expected = Object.values(challengeFiles);
const expected = challengeFiles;
expect(sorted).toEqual(expect.arrayContaining(expected));
expect(sorted.length).toEqual(expected.length);
});
it('should sort the objects into html, js, css order', () => {
const sorted = toSortedArray(challengeFiles);
const sortedKeys = sorted.map(({ key }) => key);
const sorted = challengeFiles;
const sortedKeys = sorted.map(({ fileKey }) => fileKey);
const expected = ['indexhtml', 'indexjsx', 'indexjs', 'indexcss'];
expect(sortedKeys).toStrictEqual(expected);
});