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 { createTypes } = actions;
const typeDefs = ` const typeDefs = `
type ChallengeNode implements Node { type ChallengeNode implements Node {
files: ChallengeFile challengeFiles: [FileContents]
url: String url: String
} }
type ChallengeFile {
indexcss: FileContents
indexhtml: FileContents
indexjs: FileContents
indexjsx: FileContents
}
type FileContents { type FileContents {
key: String fileKey: String
ext: String ext: String
name: String name: String
contents: String contents: String

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { HandlerProps } from 'react-reflex';
const FileType = PropTypes.shape({ export const FileType = PropTypes.shape({
key: PropTypes.string, key: PropTypes.string,
ext: PropTypes.string, ext: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
@ -24,10 +25,7 @@ export const ChallengeNode = PropTypes.shape({
challengeType: PropTypes.number, challengeType: PropTypes.number,
dashedName: PropTypes.string, dashedName: PropTypes.string,
description: PropTypes.string, description: PropTypes.string,
files: PropTypes.shape({ challengeFiles: PropTypes.array,
indexhtml: FileType,
indexjs: FileType
}),
fields: PropTypes.shape({ fields: PropTypes.shape({
slug: PropTypes.string, slug: PropTypes.string,
blockName: PropTypes.string blockName: PropTypes.string
@ -83,7 +81,7 @@ export const User = PropTypes.shape({
githubLink: PropTypes.string, githubLink: PropTypes.string,
challengeType: PropTypes.number, challengeType: PropTypes.number,
completedDate: PropTypes.number, completedDate: PropTypes.number,
files: PropTypes.array challengeFiles: PropTypes.array
}) })
), ),
email: PropTypes.string, email: PropTypes.string,
@ -176,19 +174,24 @@ export type MarkdownRemarkType = {
words: number; 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 = { export type ChallengeNodeType = {
block: string; block: string;
challengeOrder: number; challengeOrder: number;
challengeType: number; challengeType: number;
dashedName: string; dashedName: string;
description: string; description: string;
challengeFiles: ChallengeFileType[]; challengeFiles: ChallengeFiles;
fields: { fields: Fields;
slug: string;
blockName: string;
tests: TestType[];
};
files: ChallengeFileType;
forumTopicId: number; forumTopicId: number;
guideUrl: string; guideUrl: string;
head: string[]; head: string[];
@ -200,18 +203,8 @@ export type ChallengeNodeType = {
isLocked: boolean; isLocked: boolean;
isPrivate: boolean; isPrivate: boolean;
order: number; order: number;
question: { question: Question;
text: string; required: Required[];
answers: string[];
solution: number;
};
required: [
{
link: string;
raw: string;
src: string;
}
];
superOrder: number; superOrder: number;
superBlock: string; superBlock: string;
tail: string[]; tail: string[];
@ -240,7 +233,7 @@ export type AllMarkdownRemarkType = {
}; };
export type ResizePropsType = { export type ResizePropsType = {
onStopResize: (arg0: React.ChangeEvent) => void; onStopResize: (arg0: HandlerProps) => void;
onResize: () => void; onResize: () => void;
}; };
@ -249,11 +242,19 @@ export type DimensionsType = {
width: number; width: number;
}; };
export type TestType = { export type Test = {
text: string;
testString: string;
pass?: boolean; pass?: boolean;
err?: string; err?: string;
} & (ChallengeTest | CertTest);
export type ChallengeTest = {
text: string;
testString: string;
};
export type CertTest = {
id: string;
title: string;
}; };
export type UserType = { export type UserType = {
@ -311,39 +312,12 @@ export type CompletedChallenge = {
githubLink?: string; githubLink?: string;
challengeType?: number; challengeType?: number;
completedDate: number; completedDate: number;
challengeFiles: ChallengeFileType[] | null; challengeFiles: ChallengeFiles;
// TODO: remove once files->challengeFiles is refactored
files?: ChallengeFileType[] | null;
}; };
// 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 ExtTypes = 'js' | 'html' | 'css' | 'jsx';
export type FileKeyTypes = 'indexjs' | 'indexhtml' | 'indexcss'; export type FileKeyTypes = 'indexjs' | 'indexhtml' | 'indexcss';
export type ChallengeFilesType =
| {
indexcss: ChallengeFileType;
indexhtml: ChallengeFileType;
indexjs: ChallengeFileType;
indexjsx: ChallengeFileType;
}
| Record<string, never>;
export type ChallengeMetaType = { export type ChallengeMetaType = {
block: string; block: string;
id: string; id: string;
@ -383,7 +357,7 @@ export type FileKeyChallengeType = {
// think are on the node, but actually do not exist. // think are on the node, but actually do not exist.
export type ChallengeNode = { export type ChallengeNode = {
block: string; block: string;
challengeFiles: ChallengeFileType; challengeFiles: ChallengeFiles;
challengeOrder: number; challengeOrder: number;
challengeType: number; challengeType: number;
dashedName: string; dashedName: string;
@ -391,7 +365,7 @@ export type ChallengeNode = {
fields: { fields: {
slug: string; slug: string;
blockName: string; blockName: string;
tests: TestType[]; tests: Test[];
}; };
forumTopicId: number; forumTopicId: number;
// guideUrl: string; // guideUrl: string;
@ -430,7 +404,7 @@ export type ChallengeNode = {
superBlock: string; superBlock: string;
superOrder: number; superOrder: number;
template: string; template: string;
tests: TestType[]; tests: Test[];
time: string; time: string;
title: string; title: string;
translationPending: boolean; translationPending: boolean;
@ -441,3 +415,61 @@ export type ChallengeNode = {
// isPrivate: boolean; // isPrivate: boolean;
// tail: string[]; // 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 = { const propTypes = {
block: PropTypes.string, block: PropTypes.string,
challengeFiles: PropTypes.object, challengeFiles: PropTypes.array,
editor: PropTypes.element, editor: PropTypes.element,
hasEditableBoundries: PropTypes.bool, hasEditableBoundries: PropTypes.bool,
hasPreview: PropTypes.bool, hasPreview: PropTypes.bool,
@ -58,7 +58,7 @@ class DesktopLayout extends Component {
getChallengeFile() { getChallengeFile() {
const { challengeFiles } = this.props; const { challengeFiles } = this.props;
return first(Object.keys(challengeFiles).map(key => challengeFiles[key])); return first(challengeFiles);
} }
render() { render() {
@ -121,7 +121,7 @@ class DesktopLayout extends Component {
!hasEditableBoundries && <EditorTabs />} !hasEditableBoundries && <EditorTabs />}
{challengeFile && ( {challengeFile && (
<ReflexContainer <ReflexContainer
key={challengeFile.key} key={challengeFile.fileKey}
orientation='horizontal' orientation='horizontal'
> >
<ReflexElement <ReflexElement

View File

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

View File

@ -20,7 +20,7 @@ import Editor from './editor';
const propTypes = { const propTypes = {
canFocus: PropTypes.bool, canFocus: PropTypes.bool,
// TODO: use shape // TODO: use shape
challengeFiles: PropTypes.object, challengeFiles: PropTypes.array,
containerRef: PropTypes.any.isRequired, containerRef: PropTypes.any.isRequired,
contents: PropTypes.string, contents: PropTypes.string,
description: PropTypes.string, description: PropTypes.string,
@ -39,6 +39,7 @@ const propTypes = {
saveEditorContent: PropTypes.func.isRequired, saveEditorContent: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func, setEditorFocusability: PropTypes.func,
theme: PropTypes.string, theme: PropTypes.string,
// TODO: is this used?
title: PropTypes.string, title: PropTypes.string,
updateFile: PropTypes.func.isRequired, updateFile: PropTypes.func.isRequired,
visibleEditors: PropTypes.shape({ 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 // Package Utilities
import { graphql } from 'gatsby'; import { graphql } from 'gatsby';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { TFunction, withTranslation } from 'react-i18next'; import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { HandlerProps } from 'react-reflex';
import Media from 'react-responsive'; import Media from 'react-responsive';
import { bindActionCreators, Dispatch } from 'redux'; import { bindActionCreators, Dispatch } from 'redux';
import { createStructuredSelector } from 'reselect'; import { createStructuredSelector } from 'reselect';
@ -17,9 +15,10 @@ import { challengeTypes } from '../../../../utils/challenge-types';
import LearnLayout from '../../../components/layouts/learn'; import LearnLayout from '../../../components/layouts/learn';
import { import {
ChallengeNodeType, ChallengeNodeType,
ChallengeFileType, ChallengeFiles,
ChallengeFile,
ChallengeMetaType, ChallengeMetaType,
TestType, Test,
ResizePropsType ResizePropsType
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
import { isContained } from '../../../utils/is-contained'; import { isContained } from '../../../utils/is-contained';
@ -57,7 +56,7 @@ import '../components/test-frame.css';
// Redux Setup // Redux Setup
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
files: challengeFilesSelector, challengeFiles: challengeFilesSelector,
tests: challengeTestsSelector, tests: challengeTestsSelector,
output: consoleOutputSelector, output: consoleOutputSelector,
isChallengeCompleted: isChallengeCompletedSelector isChallengeCompleted: isChallengeCompletedSelector
@ -81,28 +80,28 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
interface ShowClassicProps { interface ShowClassicProps {
cancelTests: () => void; cancelTests: () => void;
challengeMounted: (arg0: string) => void; challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFileType) => void; createFiles: (arg0: ChallengeFile[]) => void;
data: { challengeNode: ChallengeNodeType }; data: { challengeNode: ChallengeNodeType };
executeChallenge: () => void; executeChallenge: () => void;
files: ChallengeFileType; challengeFiles: ChallengeFiles;
initConsole: (arg0: string) => void; initConsole: (arg0: string) => void;
initTests: (tests: TestType[]) => void; initTests: (tests: Test[]) => void;
isChallengeCompleted: boolean; isChallengeCompleted: boolean;
output: string[]; output: string[];
pageContext: { pageContext: {
challengeMeta: ChallengeMetaType; challengeMeta: ChallengeMetaType;
}; };
t: TFunction; t: TFunction;
tests: TestType[]; tests: Test[];
updateChallengeMeta: (arg0: ChallengeMetaType) => void; updateChallengeMeta: (arg0: ChallengeMetaType) => void;
} }
interface ShowClassicState { interface ShowClassicState {
layout: IReflexLayout | string; layout: ReflexLayout | string;
resizing: boolean; resizing: boolean;
} }
interface IReflexLayout { interface ReflexLayout {
codePane: { flex: number }; codePane: { flex: number };
editorPane: { flex: number }; editorPane: { flex: number };
instructionPane: { flex: number }; instructionPane: { flex: number };
@ -147,8 +146,9 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
this.instructionsPanelRef = React.createRef(); this.instructionsPanelRef = React.createRef();
} }
getLayoutState(): IReflexLayout | string { getLayoutState(): ReflexLayout | string {
const reflexLayout: IReflexLayout | string = store.get(REFLEX_LAYOUT); // 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 // Validate if user has not done any resize of the panes
if (!reflexLayout) return BASE_LAYOUT; if (!reflexLayout) return BASE_LAYOUT;
@ -168,8 +168,8 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
this.setState(state => ({ ...state, resizing: true })); this.setState(state => ({ ...state, resizing: true }));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any onStopResize(event: HandlerProps) {
onStopResize(event: any) { // @ts-expect-error TODO: Apparently, name does not exist on type
const { name, flex } = event.component.props; const { name, flex } = event.component.props;
// Only interested in tracking layout updates for ReflexElement's // Only interested in tracking layout updates for ReflexElement's
@ -236,7 +236,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
updateChallengeMeta, updateChallengeMeta,
data: { data: {
challengeNode: { challengeNode: {
files, challengeFiles,
fields: { tests }, fields: { tests },
challengeType, challengeType,
removeComments, removeComments,
@ -246,7 +246,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
pageContext: { challengeMeta } pageContext: { challengeMeta }
} = this.props; } = this.props;
initConsole(''); initConsole('');
createFiles(files); createFiles(challengeFiles ?? []);
initTests(tests); initTests(tests);
updateChallengeMeta({ updateChallengeMeta({
...challengeMeta, ...challengeMeta,
@ -260,7 +260,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
componentWillUnmount() { componentWillUnmount() {
const { createFiles, cancelTests } = this.props; const { createFiles, cancelTests } = this.props;
createFiles({}); createFiles([]);
cancelTests(); cancelTests();
} }
@ -319,13 +319,13 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
} }
renderEditor() { renderEditor() {
const { files } = this.props; const { challengeFiles } = this.props;
const { description, title } = this.getChallenge(); const { description, title } = this.getChallenge();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return ( return (
files && ( challengeFiles && (
<MultifileEditor <MultifileEditor
challengeFiles={files} challengeFiles={challengeFiles}
containerRef={this.containerRef} containerRef={this.containerRef}
description={description} description={description}
editorRef={this.editorRef} editorRef={this.editorRef}
@ -358,11 +358,11 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
} }
hasEditableBoundries() { hasEditableBoundries() {
const { files } = this.props; const { challengeFiles } = this.props;
return Object.values(files).some( return (
file => challengeFiles?.some(
file?.editableRegionBoundaries && challengeFile => challengeFile.editableRegionBoundaries?.length === 2
file.editableRegionBoundaries.length === 2 ) ?? false
); );
} }
@ -379,7 +379,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
pageContext: { pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath } challengeMeta: { nextChallengePath, prevChallengePath }
}, },
files, challengeFiles,
t t
} = this.props; } = this.props;
@ -414,7 +414,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
<Media minWidth={MAX_MOBILE_WIDTH + 1}> <Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout <DesktopLayout
block={block} block={block}
challengeFiles={files} challengeFiles={challengeFiles}
editor={this.renderEditor()} editor={this.renderEditor()}
hasEditableBoundries={this.hasEditableBoundries()} hasEditableBoundries={this.hasEditableBoundries()}
hasPreview={this.hasPreview()} hasPreview={this.hasPreview()}
@ -478,43 +478,14 @@ export const query = graphql`
link link
src src
} }
files { challengeFiles {
indexcss { fileKey
key ext
ext name
name contents
contents head
head tail
tail editableRegionBoundaries
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 { Loader } from '../../../components/helpers';
import { userSelector, isDonationModalOpenSelector } from '../../../redux'; import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import { import {
ChallengeFileType, ChallengeFiles,
DimensionsType, DimensionsType,
ExtTypes, ExtTypes,
FileKeyTypes, FileKeyTypes,
ResizePropsType, ResizePropsType,
TestType Test
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
import { import {
@ -45,7 +45,7 @@ const MonacoEditor = Loadable(() => import('react-monaco-editor'));
interface EditorProps { interface EditorProps {
canFocus: boolean; canFocus: boolean;
challengeFiles: ChallengeFileType; challengeFiles: ChallengeFiles;
containerRef: RefObject<HTMLElement>; containerRef: RefObject<HTMLElement>;
contents: string; contents: string;
description: string; description: string;
@ -61,11 +61,11 @@ interface EditorProps {
saveEditorContent: () => void; saveEditorContent: () => void;
setEditorFocusability: (isFocusable: boolean) => void; setEditorFocusability: (isFocusable: boolean) => void;
submitChallenge: () => void; submitChallenge: () => void;
tests: TestType[]; tests: Test[];
theme: string; theme: string;
title: string; title: string;
updateFile: (objest: { updateFile: (object: {
key: FileKeyTypes; fileKey: FileKeyTypes;
editorValue: string; editorValue: string;
editableRegionBoundaries: number[] | null; editableRegionBoundaries: number[] | null;
}) => void; }) => void;
@ -242,7 +242,9 @@ const Editor = (props: EditorProps): JSX.Element => {
const getEditableRegion = () => { const getEditableRegion = () => {
const { challengeFiles, fileKey } = props; const { challengeFiles, fileKey } = props;
const edRegBounds = challengeFiles[fileKey]?.editableRegionBoundaries; const edRegBounds = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
)?.editableRegionBoundaries;
return edRegBounds ? [...edRegBounds] : []; 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 // swap and reuse models, we have to create our own models to prevent
// disposal. // disposal.
const challengeFile = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
);
const model = const model =
data.model || data.model ||
monaco.editor.createModel( monaco.editor.createModel(
challengeFiles[fileKey]?.contents ?? '', challengeFile?.contents ?? '',
modeMap[challengeFiles[fileKey]?.ext ?? 'html'] modeMap[challengeFile?.ext ?? 'html']
); );
data.model = model; data.model = model;
const editableRegion = getEditableRegion(); const editableRegion = getEditableRegion();
@ -277,7 +282,9 @@ const Editor = (props: EditorProps): JSX.Element => {
const { challengeFiles, fileKey } = props; const { challengeFiles, fileKey } = props;
const { model } = dataRef.current[fileKey]; const { model } = dataRef.current[fileKey];
const newContents = challengeFiles[fileKey]?.contents; const newContents = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
)?.contents;
if (model?.getValue() !== newContents) { if (model?.getValue() !== newContents) {
model?.setValue(newContents ?? ''); model?.setValue(newContents ?? '');
} }
@ -576,9 +583,7 @@ const Editor = (props: EditorProps): JSX.Element => {
} }
const onChange = (editorValue: string) => { const onChange = (editorValue: string) => {
const { updateFile } = props; const { updateFile, fileKey } = props;
// TODO: use fileKey everywhere?
const { fileKey: key } = props;
// TODO: now that we have getCurrentEditableRegion, should the overlays // TODO: now that we have getCurrentEditableRegion, should the overlays
// follow that directly? We could subscribe to changes to that and redraw if // 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 // 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.startLineNumber - 1,
editableRegion.endLineNumber + 1 editableRegion.endLineNumber + 1
]; ];
updateFile({ key, editorValue, editableRegionBoundaries }); updateFile({ fileKey, editorValue, editableRegionBoundaries });
}; };
function showEditableRegion(editableBoundaries: number[]) { function showEditableRegion(editableBoundaries: number[]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,7 +49,7 @@ const applyFunction = fn =>
const composeFunctions = (...fns) => const composeFunctions = (...fns) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g)); 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 // 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 // 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 // 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 // A better solution is to store and handle them separately. Perhaps never
// setting the name to 'index'. Use 'contents' instead? // setting the name to 'index'. Use 'contents' instead?
// TODO: is file.source ever defined? // TODO: is file.source ever defined?
return files.reduce( const source = challengeFiles.reduce(
(sources, file) => { (sources, challengeFile) => {
sources[file.name] += file.source || file.contents; sources[challengeFile.name] +=
sources.editableContents += file.editableContents || ''; challengeFile.source || challengeFile.contents;
sources.editableContents += challengeFile.editableContents || '';
return sources; return sources;
}, },
{ index: '', editableContents: '' } { index: '', editableContents: '' }
); );
return source;
} }
function checkFilesErrors(files) { function checkFilesErrors(challengeFiles) {
const errors = files.filter(({ error }) => error).map(({ error }) => error); const errors = challengeFiles
.filter(({ error }) => error)
.map(({ error }) => error);
if (errors.length) { if (errors.length) {
throw errors; throw errors;
} }
return files; return challengeFiles;
} }
const buildFunctions = { const buildFunctions = {
@ -140,41 +144,48 @@ async function getDOMTestRunner(buildData, { proxyLogger }, document) {
runTestInTestFrame(document, testString, testTimeout); runTestInTestFrame(document, testString, testTimeout);
} }
export function buildDOMChallenge({ files, required = [], template = '' }) { export function buildDOMChallenge({
challengeFiles,
required = [],
template = ''
}) {
const finalRequires = [...globalRequires, ...required, ...frameRunner]; 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 toHtml = [jsToHtml, cssToHtml];
const pipeLine = composeFunctions(...getTransformers(), ...toHtml); const pipeLine = composeFunctions(...getTransformers(), ...toHtml);
const finalFiles = Object.keys(files) const finalFiles = challengeFiles.map(pipeLine);
.map(key => files[key])
.map(pipeLine);
return Promise.all(finalFiles) return Promise.all(finalFiles)
.then(checkFilesErrors) .then(checkFilesErrors)
.then(files => ({ .then(challengeFiles => ({
challengeType: challengeTypes.html, challengeType: challengeTypes.html,
build: concatHtml({ required: finalRequires, template, files }), build: concatHtml({ required: finalRequires, template, challengeFiles }),
sources: buildSourceMap(files), sources: buildSourceMap(challengeFiles),
loadEnzyme loadEnzyme
})); }));
} }
export function buildJSChallenge({ files }, options) { export function buildJSChallenge({ challengeFiles }, options) {
const pipeLine = composeFunctions(...getTransformers(options)); const pipeLine = composeFunctions(...getTransformers(options));
const finalFiles = Object.keys(files) const finalFiles = challengeFiles.map(pipeLine);
.map(key => files[key])
.map(pipeLine);
return Promise.all(finalFiles) return Promise.all(finalFiles)
.then(checkFilesErrors) .then(checkFilesErrors)
.then(files => ({ .then(challengeFiles => ({
challengeType: challengeTypes.js, challengeType: challengeTypes.js,
build: files build: challengeFiles
.reduce( .reduce(
(body, file) => [...body, file.head, file.contents, file.tail], (body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[] []
) )
.join('\n'), .join('\n'),
sources: buildSourceMap(files) sources: buildSourceMap(challengeFiles)
})); }));
} }

View File

@ -4,11 +4,11 @@ import { toSortedArray } from '../../../../../utils/sort-files';
export function getTargetEditor(challengeFiles) { export function getTargetEditor(challengeFiles) {
if (isEmpty(challengeFiles)) return null; if (isEmpty(challengeFiles)) return null;
else { else {
let targetEditor = Object.values(challengeFiles).find( let targetEditor = challengeFiles.find(
({ editableRegionBoundaries }) => !isEmpty(editableRegionBoundaries) ({ editableRegionBoundaries }) => !isEmpty(editableRegionBoundaries)
)?.key; )?.fileKey;
// fallback for when there is no editable region. // 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 path = require('path');
const util = require('util'); const util = require('util');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const { findIndex, reduce, toString } = require('lodash'); const { findIndex } = require('lodash');
const readDirP = require('readdirp'); const readDirP = require('readdirp');
const { helpCategoryMap } = require('../client/utils/challenge-types'); const { helpCategoryMap } = require('../client/utils/challenge-types');
const { showUpcomingChanges } = require('../config/env.json'); const { showUpcomingChanges } = require('../config/env.json');
@ -306,44 +306,21 @@ ${getFullPath('english')}
return prepareChallenge(challenge); 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 // gets the challenge ready for sourcing into Gatsby
function prepareChallenge(challenge) { function prepareChallenge(challenge) {
if (challenge.files) { if (challenge.challengeFiles) {
challenge.files = filesToObject(challenge.files); challenge.challengeFiles = challenge.challengeFiles.reduce(
challenge.files = Object.keys(challenge.files) (challengeFiles, challengeFile) => {
.filter(key => challenge.files[key]) return [
.map(key => challenge.files[key]) ...challengeFiles,
.reduce( {
(files, file) => ({ ...createPoly(challengeFile),
...files, seed: challengeFile.contents.slice(0)
[file.key]: {
...createPoly(file),
seed: file.contents.slice(0)
} }
}), ];
{} },
); []
} );
if (challenge.solutionFiles) {
challenge.solutionFiles = filesToObject(challenge.solutionFiles);
} }
return challenge; return challenge;
} }
@ -381,10 +358,6 @@ function getBlockNameFromPath(filePath) {
return block; return block;
} }
function arrToString(arr) {
return Array.isArray(arr) ? arr.join('\n') : toString(arr);
}
exports.hasEnglishSource = hasEnglishSource; exports.hasEnglishSource = hasEnglishSource;
exports.parseTranslation = parseTranslation; exports.parseTranslation = parseTranslation;
exports.createChallenge = createChallenge; exports.createChallenge = createChallenge;

View File

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

View File

@ -307,16 +307,16 @@ function populateTestsForLang({ lang, challenges, meta }) {
return; return;
} }
// If no .files, then no seed: // If no .challengeFiles, then no seed:
if (!challenge.files) return; if (!challenge.challengeFiles) return;
// - None of the translatable comments should appear in the // - None of the translatable comments should appear in the
// translations. While this is a crude check, no challenges // translations. While this is a crude check, no challenges
// currently have the text of a comment elsewhere. If that happens // currently have the text of a comment elsewhere. If that happens
// we can handle that challenge separately. // we can handle that challenge separately.
TRANSLATABLE_COMMENTS.forEach(comment => { TRANSLATABLE_COMMENTS.forEach(comment => {
Object.values(challenge.files).forEach(file => { challenge.challengeFiles.forEach(challengeFile => {
if (file.contents.includes(comment)) if (challengeFile.contents.includes(comment))
throw Error( throw Error(
`English comment '${comment}' should be replaced with its translation` `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 // - None of the translated comment texts should appear *outside* a
// comment // comment
Object.values(challenge.files).forEach(file => { challenge.challengeFiles.forEach(challengeFile => {
let comments = {}; let comments = {};
// We get all the actual comments using the appropriate parsers // We get all the actual comments using the appropriate parsers
if (file.ext === 'html') { if (challengeFile.ext === 'html') {
const commentTypes = ['css', 'html', 'scriptJs']; const commentTypes = ['css', 'html', 'scriptJs'];
for (let type of commentTypes) { 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)) { for (const [key, value] of Object.entries(newComments)) {
comments[key] = comments[key] comments[key] = comments[key]
? comments[key] + value ? comments[key] + value
@ -340,7 +342,9 @@ function populateTestsForLang({ lang, challenges, meta }) {
} }
} }
} else { } 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 // Then we compare the number of times each comment appears in the
@ -409,7 +413,7 @@ ${inspect(commentMap)}
try { try {
testRunner = await createTestRunner( testRunner = await createTestRunner(
challenge, challenge,
'', [],
buildChallenge buildChallenge
); );
} catch { } catch {
@ -448,12 +452,13 @@ ${inspect(commentMap)}
// TODO: can this be dried out, ideally by removing the redux // TODO: can this be dried out, ideally by removing the redux
// handler? // handler?
if (nextChallenge) { if (nextChallenge) {
const solutionFiles = cloneDeep(nextChallenge.files); const solutionFiles = cloneDeep(nextChallenge.challengeFiles);
Object.keys(solutionFiles).forEach(key => { solutionFiles.forEach(challengeFile => {
const file = solutionFiles[key]; challengeFile.editableContents = getLines(
file.editableContents = getLines( challengeFile.contents,
file.contents, challenge.challengeFiles.find(
challenge.files[key].editableRegionBoundaries x => x.fileKey === challengeFile.fileKey
).editableRegionBoundaries
); );
}); });
solutions = [solutionFiles]; solutions = [solutionFiles];
@ -470,7 +475,9 @@ ${inspect(commentMap)}
const filteredSolutions = solutionsAsArrays.filter(solution => { const filteredSolutions = solutionsAsArrays.filter(solution => {
return !isEmpty( 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( async function createTestRunner(
challenge, challenge,
solution, solutionFiles,
buildChallenge, buildChallenge,
solutionFromNext solutionFromNext
) { ) {
const { required = [], template, removeComments } = challenge; const { required = [], template, removeComments } = challenge;
// we should avoid modifying challenge, as it gets reused: // we should avoid modifying challenge, as it gets reused:
const files = cloneDeep(challenge.files); const challengeFiles = cloneDeep(challenge.challengeFiles);
solutionFiles.forEach(solutionFile => {
Object.keys(solution).forEach(key => { const challengeFile = challengeFiles.find(
files[key].contents = solution[key].contents; x => x.fileKey === solutionFile.fileKey
files[key].editableContents = solution[key].editableContents; );
challengeFile.contents = solutionFile.contents;
challengeFile.editableContents = solutionFile.editableContents;
}); });
const { build, sources, loadEnzyme } = await buildChallenge({ const { build, sources, loadEnzyme } = await buildChallenge({
files, challengeFiles,
required, required,
template 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> :::</p>
</section>", </section>",
"solutions": Array [ "solutions": Array [
Object {}, Array [],
], ],
"tests": Array [], "tests": Array [],
} }
@ -44,7 +44,7 @@ Object {
</code></pre>", </code></pre>",
}, },
"solutions": Array [ "solutions": Array [
Object {}, Array [],
], ],
"tests": Array [], "tests": Array [],
} }
@ -52,38 +52,33 @@ Object {
exports[`challenge parser should import md from other files 1`] = ` exports[`challenge parser should import md from other files 1`] = `
Object { Object {
"description": "<section id=\\"description\\"> "challengeFiles": Array [
<p>Paragraph 1</p> Object {
<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 {
"contents": "<html> "contents": "<html>
<body> <body>
</body> </body>
</html>", </html>",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
"indexjs": Object { Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y'; "contents": "var x = 'y';
for (let index = 0; index < array.length; index++) { for (let index = 0; index < array.length; index++) {
const element = array[index]; const element = array[index];
@ -91,20 +86,25 @@ for (let index = 0; index < array.length; index++) {
}", }",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "custom-name", "id": "custom-name",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"instructions": "<section id=\\"instructions\\"> "instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p> <p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0 <pre><code class=\\"language-html\\">code example 0
</code></pre> </code></pre>
</section>", </section>",
"solutions": Array [ "solutions": Array [
Object {}, Array [],
], ],
"tests": Array [ "tests": Array [
Object { 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`] = ` exports[`challenge parser should not mix other YAML with the frontmatter 1`] = `
Object { 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\\"> "description": "<section id=\\"description\\">
<p>Paragraph 1</p> <p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example <pre><code class=\\"language-html\\">code example
@ -130,50 +167,13 @@ Object {
anothersubkey: another value anothersubkey: another value
</code></pre> </code></pre>
</section>", </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\\"> "instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p> <p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0 <pre><code class=\\"language-html\\">code example 0
</code></pre> </code></pre>
</section>", </section>",
"solutions": Array [ "solutions": Array [
Object {}, Array [],
], ],
"tests": Array [ "tests": Array [
Object { Object {
@ -190,42 +190,8 @@ Object {
exports[`challenge parser should parse a more realistic md file 1`] = ` exports[`challenge parser should parse a more realistic md file 1`] = `
Object { Object {
"description": "<section id=\\"description\\"> "challengeFiles": Array [
<p>When you add a lower rank heading element to the page, it's implied that you're starting a new subsection.</p> Object {
<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 {
"contents": "<html> "contents": "<html>
<body> <body>
<h1>CatPhotoApp</h1> <h1>CatPhotoApp</h1>
@ -256,32 +222,14 @@ a {
23, 23,
], ],
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "html-key", "id": "html-key",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "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 { Object {
"indexcss": Object { "contents": "body {
"contents": "body {
background: white; background: white;
} }
@ -293,14 +241,46 @@ h1 {
a { a {
color: green; color: green;
}", }",
"ext": "css", "editableRegionBoundaries": Array [
"head": "", 7,
"id": "", 9,
"key": "indexcss", ],
"name": "index", "ext": "css",
"tail": "", "fileKey": "indexcss",
}, "head": "",
"indexhtml": Object { "id": "",
"name": "index",
"tail": "",
},
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> "contents": "<html>
<body> <body>
<h1>CatPhotoApp</h1> <h1>CatPhotoApp</h1>
@ -327,22 +307,42 @@ a {
</body> </body>
</html>", </html>",
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "html-key", "id": "html-key",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "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';", "contents": "var x = 'y';",
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "final-key", "id": "final-key",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
], ],
"tests": Array [ "tests": Array [
Object { Object {
@ -385,89 +385,89 @@ assert(
exports[`challenge parser should parse a simple md file 1`] = ` exports[`challenge parser should parse a simple md file 1`] = `
Object { Object {
"description": "<section id=\\"description\\"> "challengeFiles": Array [
<p>Paragraph 1</p> Object {
<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 {
"contents": "<html> "contents": "<html>
<body> <body>
</body> </body>
</html>", </html>",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
"indexjs": Object { Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';", "contents": "var x = 'y';",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"instructions": "<section id=\\"instructions\\"> "instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p> <p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0 <pre><code class=\\"language-html\\">code example 0
</code></pre> </code></pre>
</section>", </section>",
"solutions": Array [ "solutions": Array [
Object { Array [
"indexcss": Object { Object {
"contents": "body {
background: white;
}",
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html> "contents": "<html>
<body> <body>
</body> </body>
</html>", </html>",
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "html-key", "id": "html-key",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
"indexjs": Object { Object {
"contents": "body {
background: white;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y'; "contents": "var x = 'y';
\`\`", \`\`",
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
], ],
"tests": Array [ "tests": Array [
Object { Object {
@ -491,54 +491,54 @@ if(let x of xs) {
exports[`challenge parser should parse frontmatter 1`] = ` exports[`challenge parser should parse frontmatter 1`] = `
Object { Object {
"challengeType": 0, "challengeFiles": Array [
"description": "<section id=\\"description\\"> Object {
<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 {
"contents": "<html> "contents": "<html>
<body> <body>
</body> </body>
</html>", </html>",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
"indexjs": Object { Object {
"contents": "body {
background: green;
}",
"editableRegionBoundaries": Array [],
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y';", "contents": "var x = 'y';",
"editableRegionBoundaries": Array [], "editableRegionBoundaries": Array [],
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
"challengeType": 0,
"description": "<section id=\\"description\\">
<p>Paragraph 1</p>
<pre><code class=\\"language-html\\">code example
</code></pre>
</section>",
"forumTopicId": 18276, "forumTopicId": 18276,
"id": "bd7123c8c441eddfaeb5bdef", "id": "bd7123c8c441eddfaeb5bdef",
"isHidden": false, "isHidden": false,
"solutions": Array [ "solutions": Array [
Object {}, Array [],
], ],
"tests": Array [ "tests": Array [
Object { Object {
@ -557,6 +557,43 @@ Object {
exports[`challenge parser should parse gfm strikethrough and frontmatter 1`] = ` exports[`challenge parser should parse gfm strikethrough and frontmatter 1`] = `
Object { 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\\"> "description": "<section id=\\"description\\">
<p>Paragraph 1 <del>Strikethrough text</del>. https://should.not.be.autolinked</p> <p>Paragraph 1 <del>Strikethrough text</del>. https://should.not.be.autolinked</p>
<pre><code class=\\"language-html\\">code example <pre><code class=\\"language-html\\">code example
@ -576,84 +613,47 @@ Object {
</tbody> </tbody>
</table> </table>
</section>", </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\\"> "instructions": "<section id=\\"instructions\\">
<p>Paragraph 0</p> <p>Paragraph 0</p>
<pre><code class=\\"language-html\\">code example 0 <pre><code class=\\"language-html\\">code example 0
</code></pre> </code></pre>
</section>", </section>",
"solutions": Array [ "solutions": Array [
Object { Array [
"indexcss": Object { Object {
"contents": "body {
background: white;
}",
"ext": "css",
"head": "",
"id": "",
"key": "indexcss",
"name": "index",
"tail": "",
},
"indexhtml": Object {
"contents": "<html> "contents": "<html>
<body> <body>
</body> </body>
</html>", </html>",
"ext": "html", "ext": "html",
"fileKey": "indexhtml",
"head": "", "head": "",
"id": "html-key", "id": "html-key",
"key": "indexhtml",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
"indexjs": Object { Object {
"contents": "body {
background: white;
}",
"ext": "css",
"fileKey": "indexcss",
"head": "",
"id": "",
"name": "index",
"tail": "",
},
Object {
"contents": "var x = 'y'; "contents": "var x = 'y';
\`\`", \`\`",
"ext": "js", "ext": "js",
"fileKey": "indexjs",
"head": "", "head": "",
"id": "", "id": "",
"key": "indexjs",
"name": "index", "name": "index",
"tail": "", "tail": "",
}, },
}, ],
], ],
"tests": Array [ "tests": Array [
Object { Object {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,16 +33,18 @@ describe('add solution plugin', () => {
expect(file.data.solutions.every(el => isObject(el))).toBe(true); 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); expect.assertions(15);
plugin(mockAST, file); plugin(mockAST, file);
const { const {
data: { solutions } data: { solutions }
} = file; } = file;
const testObject = solutions[0].indexjs; const testObject = solutions[0].find(
solution => solution.fileKey === 'indexjs'
);
expect(Object.keys(testObject).length).toEqual(7); expect(Object.keys(testObject).length).toEqual(7);
expect(testObject).toHaveProperty('key'); expect(testObject).toHaveProperty('fileKey');
expect(typeof testObject['key']).toBe('string'); expect(typeof testObject['fileKey']).toBe('string');
expect(testObject).toHaveProperty('ext'); expect(testObject).toHaveProperty('ext');
expect(typeof testObject['ext']).toBe('string'); expect(typeof testObject['ext']).toBe('string');
expect(testObject).toHaveProperty('name'); expect(testObject).toHaveProperty('name');
@ -64,16 +66,24 @@ describe('add solution plugin', () => {
data: { solutions } data: { solutions }
} = file; } = file;
expect(solutions.length).toBe(3); expect(solutions.length).toBe(3);
expect(solutions[0].indexjs.contents).toBe("var x = 'y';"); expect(
expect(solutions[1].indexhtml.contents).toBe(`<html> solutions[0].find(solution => solution.fileKey === 'indexjs').contents
).toBe("var x = 'y';");
expect(
solutions[1].find(solution => solution.fileKey === 'indexhtml').contents
).toBe(`<html>
<body> <body>
solution number two solution number two
</body> </body>
</html>`); </html>`);
expect(solutions[1].indexcss.contents).toBe(`body { expect(
solutions[1].find(solution => solution.fileKey === 'indexcss').contents
).toBe(`body {
background: white; 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', () => { 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) { function defaultFile(lang, id) {
return { return {
key: `index${lang}`, fileKey: `index${lang}`,
ext: lang, ext: lang,
name: 'index', name: 'index',
contents: '', contents: '',
@ -43,21 +43,21 @@ function codeToData(node, seeds, seedKey, validate) {
Please use one of js, css, html, jsx or py Please use one of js, css, html, jsx or py
`); `);
const key = `index${lang}`; const fileKey = `index${lang}`;
const id = seeds[key] ? seeds[key].id : ''; const id = seeds[fileKey] ? seeds[fileKey].id : '';
// the contents will be missing if there is an id preceding this code // the contents will be missing if there is an id preceding this code
// block. // block.
if (!seeds[key]) { if (!seeds[fileKey]) {
seeds[key] = defaultFile(lang, id); seeds[fileKey] = defaultFile(lang, id);
} }
if (isEmpty(node.value) && seedKey !== 'contents') { if (isEmpty(node.value) && seedKey !== 'contents') {
const section = keyToSection[seedKey]; const section = keyToSection[seedKey];
throw Error(`Empty code block in --${section}-- section`); throw Error(`Empty code block in --${section}-- section`);
} }
seeds[key][seedKey] = isEmpty(seeds[key][seedKey]) seeds[fileKey][seedKey] = isEmpty(seeds[fileKey][seedKey])
? node.value ? node.value
: seeds[key][seedKey] + '\n' + node.value; : seeds[fileKey][seedKey] + '\n' + node.value;
} }
function idToData(node, index, parent, seeds) { function idToData(node, index, parent, seeds) {
@ -73,9 +73,9 @@ function idToData(node, index, parent, seeds) {
} }
const codeNode = parent.children[index + 1]; const codeNode = parent.children[index + 1];
if (codeNode && is(codeNode, 'code')) { if (codeNode && is(codeNode, 'code')) {
const key = `index${codeNode.lang}`; const fileKey = `index${codeNode.lang}`;
if (seeds[key]) throw Error('::id{#id}s must come before code blocks'); if (seeds[fileKey]) throw Error('::id{#id}s must come before code blocks');
seeds[key] = defaultFile(codeNode.lang, id); seeds[fileKey] = defaultFile(codeNode.lang, id);
} else { } else {
throw Error('::id{#id}s must come before code blocks'); 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'], solutions: ['solution html string'],
description: 'description html string', description: 'description html string',
instructions: 'instructions html string', instructions: 'instructions html string',
files: [] challengeFiles: []
}; };
exports.ENGLISH_CHALLENGE_NO_FILES = ENGLISH_CHALLENGE_NO_FILES; 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) => { exports.translateCommentsInChallenge = (challenge, lang, dict) => {
const challClone = cloneDeep(challenge); const challClone = cloneDeep(challenge);
if (!challClone.files) { if (!challClone.challengeFiles) {
console.warn(`Challenge ${challClone.title} has no seed to translate`); console.warn(`Challenge ${challClone.title} has no seed to translate`);
} else { } else {
Object.keys(challClone.files).forEach(key => { challClone.challengeFiles.forEach(challengeFile => {
if (challClone.files[key].contents) { if (challengeFile.contents) {
let { text, commentCounts } = this.translateComments( let { text, commentCounts } = this.translateComments(
challenge.files[key].contents, challengeFile.contents,
lang, lang,
dict, dict,
challClone.files[key].ext challengeFile.ext
); );
challClone.__commentCounts = commentCounts; challClone.__commentCounts = commentCounts;
challClone.files[key].contents = text; challengeFile.contents = text;
} }
}); });
} }

View File

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

View File

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

View File

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

View File

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