diff --git a/client/src/templates/Challenges/classic/ActionRow.js b/client/src/templates/Challenges/classic/ActionRow.js index c53f5ff740..e19e9fbf61 100644 --- a/client/src/templates/Challenges/classic/ActionRow.js +++ b/client/src/templates/Challenges/classic/ActionRow.js @@ -1,13 +1,58 @@ import React from 'react'; - +import PropTypes from 'prop-types'; import EditorTabs from './EditorTabs'; -const ActionRow = () => ( -
- -
-); +const propTypes = { + block: PropTypes.string, + showConsole: PropTypes.bool, + showNotes: PropTypes.bool, + showPreview: PropTypes.bool, + superBlock: PropTypes.string, + switchDisplayTab: PropTypes.func +}; +const ActionRow = ({ switchDisplayTab, showPreview, showConsole }) => { + const restartStep = () => { + console.log('restart'); + }; + return ( +
+
+
+ Responsive Web Design > Basic HTML Cat Photo App >{' '} + Step 23 of 213 +
+
+
+ + +
+ + +
+
+
+ ); +}; + +ActionRow.propTypes = propTypes; ActionRow.displayName = 'ActionRow'; - export default ActionRow; diff --git a/client/src/templates/Challenges/classic/DesktopLayout.js b/client/src/templates/Challenges/classic/DesktopLayout.js index 8961e6863a..f342e40334 100644 --- a/client/src/templates/Challenges/classic/DesktopLayout.js +++ b/client/src/templates/Challenges/classic/DesktopLayout.js @@ -29,6 +29,20 @@ const reflexProps = { }; class DesktopLayout extends Component { + constructor(props) { + super(props); + this.state = { showNotes: false, showPreview: true, showConsole: false }; + this.switchDisplayTab = this.switchDisplayTab.bind(this); + } + + switchDisplayTab(displayTab) { + this.setState(state => { + return { + [displayTab]: !state[displayTab] + }; + }); + } + getChallengeFile() { const { challengeFiles } = this.props; return first(Object.keys(challengeFiles).map(key => challengeFiles[key])); @@ -45,16 +59,30 @@ class DesktopLayout extends Component { hasEditableBoundries } = this.props; + const { showPreview, showConsole } = this.state; + const challengeFile = this.getChallengeFile(); + const projectBasedChallenge = showUpcomingChanges && hasEditableBoundries; + const isPreviewDisplayable = projectBasedChallenge + ? showPreview && hasPreview + : hasPreview; + const isConsoleDisplayable = projectBasedChallenge ? showConsole : true; return ( - {showUpcomingChanges && hasEditableBoundries && } + {projectBasedChallenge && ( + + )} - - {instructions} - - + {!projectBasedChallenge && ( + + {instructions} + + )} + {!projectBasedChallenge && ( + + )} + {challengeFile && ( @@ -68,15 +96,21 @@ class DesktopLayout extends Component { } - - - {testOutput} - + {isConsoleDisplayable && ( + + )} + {isConsoleDisplayable && ( + + {testOutput} + + )} )} - {hasPreview && } - {hasPreview && ( + {isPreviewDisplayable && ( + + )} + {isPreviewDisplayable && ( {preview} diff --git a/client/src/templates/Challenges/classic/Editor.js b/client/src/templates/Challenges/classic/Editor.js index 2bcbe75c3c..91749d7172 100644 --- a/client/src/templates/Challenges/classic/Editor.js +++ b/client/src/templates/Challenges/classic/Editor.js @@ -12,7 +12,9 @@ import { saveEditorContent, setEditorFocusability, setAccessibilityMode, - updateFile + updateFile, + challengeTestsSelector, + submitChallenge } from '../redux'; import { userSelector, isDonationModalOpenSelector } from '../../../redux'; import { Loader } from '../../../components/helpers'; @@ -43,6 +45,8 @@ const propTypes = { saveEditorContent: PropTypes.func.isRequired, setAccessibilityMode: PropTypes.func.isRequired, setEditorFocusability: PropTypes.func, + submitChallenge: PropTypes.func, + tests: PropTypes.arrayOf(PropTypes.object), theme: PropTypes.string, updateFile: PropTypes.func.isRequired }; @@ -53,11 +57,20 @@ const mapStateToProps = createSelector( inAccessibilityModeSelector, isDonationModalOpenSelector, userSelector, - (canFocus, output, accessibilityMode, open, { theme = 'default' }) => ({ + challengeTestsSelector, + ( + canFocus, + output, + accessibilityMode, + open, + { theme = 'default' }, + tests + ) => ({ canFocus: open ? false : canFocus, output, inAccessibilityMode: accessibilityMode, - theme + theme, + tests }) ); @@ -66,7 +79,8 @@ const mapDispatchToProps = { saveEditorContent, setAccessibilityMode, setEditorFocusability, - updateFile + updateFile, + submitChallenge }; const modeMap = { @@ -140,6 +154,7 @@ class Editor extends Component { viewZoneId: null, startEditDecId: null, endEditDecId: null, + insideEditDecId: null, viewZoneHeight: null }; @@ -182,6 +197,13 @@ class Editor extends Component { this.focusOnEditor = this.focusOnEditor.bind(this); } + getEditableRegion = () => { + const { challengeFiles, fileKey } = this.props; + return challengeFiles[fileKey].editableRegionBoundaries + ? [...challengeFiles[fileKey].editableRegionBoundaries] + : []; + }; + editorWillMount = monaco => { this._monaco = monaco; const { challengeFiles, fileKey } = this.props; @@ -201,9 +223,7 @@ class Editor extends Component { ); this.data.model = model; - const editableRegion = challengeFiles[fileKey].editableRegionBoundaries - ? [...challengeFiles[fileKey].editableRegionBoundaries] - : []; + const editableRegion = this.getEditableRegion(); if (editableRegion.length === 2) this.decorateForbiddenRanges(editableRegion); @@ -224,7 +244,6 @@ class Editor extends Component { editorDidMount = (editor, monaco) => { this._editor = editor; - const { challengeFiles, fileKey } = this.props; editor.updateOptions({ accessibilitySupport: this.props.inAccessibilityMode ? 'on' : 'auto' }); @@ -286,9 +305,7 @@ class Editor extends Component { } }); - const editableBoundaries = challengeFiles[fileKey].editableRegionBoundaries - ? [...challengeFiles[fileKey].editableRegionBoundaries] - : []; + const editableBoundaries = this.getEditableRegion(); if (editableBoundaries.length === 2) { // TODO: is there a nicer approach/way of organising everything that @@ -363,7 +380,7 @@ class Editor extends Component { this.data.viewZoneHeight = domNode.offsetHeight; var background = document.createElement('div'); - background.style.background = 'lightgreen'; + // background.style.background = 'lightgreen'; // We have to wait for the viewZone to finish rendering before adjusting the // position of the overlayWidget (i.e. trigger it via onComputedHeight). If @@ -392,7 +409,7 @@ class Editor extends Component { this.data.outputZoneHeight = outputNode.offsetHeight; var background = document.createElement('div'); - background.style.background = 'lightpink'; + // background.style.background = 'lightpink'; // We have to wait for the viewZone to finish rendering before adjusting the // position of the overlayWidget (i.e. trigger it via onComputedHeight). If @@ -413,19 +430,14 @@ class Editor extends Component { const { description } = this.props; var domNode = document.createElement('div'); var desc = document.createElement('div'); - var button = document.createElement('button'); - button.innerHTML = 'Run the Tests (Ctrl + Enter)'; - button.onclick = () => { - const { executeChallenge } = this.props; - executeChallenge(); - }; - - domNode.appendChild(desc); - domNode.appendChild(button); + var descContainer = document.createElement('div'); + descContainer.classList.add('description-container'); + domNode.classList.add('editor-upper-jaw'); + domNode.appendChild(descContainer); + descContainer.appendChild(desc); desc.innerHTML = description; - - desc.style.background = 'white'; - domNode.style.background = 'lightgreen'; + // desc.style.background = 'white'; + // domNode.style.background = 'lightgreen'; // TODO: the solution is probably just to use an overlay that's forced to // follow the decorations. // TODO: this is enough for Firefox, but Chrome needs more before the @@ -436,7 +448,7 @@ class Editor extends Component { domNode.setAttribute('aria-hidden', true); - domNode.style.background = 'lightYellow'; + // domNode.style.background = 'lightYellow'; domNode.style.left = this._editor.getLayoutInfo().contentLeft + 'px'; domNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px'; domNode.style.top = this.getViewZoneTop(); @@ -449,11 +461,23 @@ class Editor extends Component { const outputNode = document.createElement('div'); const statusNode = document.createElement('div'); const hintNode = document.createElement('div'); - outputNode.appendChild(statusNode); - outputNode.appendChild(hintNode); + const editorActionRow = document.createElement('div'); + editorActionRow.classList.add('action-row-container'); + outputNode.classList.add('editor-lower-jaw'); + outputNode.appendChild(editorActionRow); hintNode.setAttribute('id', 'test-output'); statusNode.setAttribute('id', 'test-status'); - statusNode.innerHTML = '// tests'; + var button = document.createElement('button'); + button.setAttribute('id', 'test-button'); + button.classList.add('btn-block'); + button.innerHTML = 'Check Your Code (Ctrl + Enter)'; + editorActionRow.appendChild(button); + editorActionRow.appendChild(statusNode); + editorActionRow.appendChild(hintNode); + button.onclick = () => { + const { executeChallenge } = this.props; + executeChallenge(); + }; // TODO: does it? // The z-index needs increasing as ViewZones default to below the lines. @@ -528,6 +552,19 @@ class Editor extends Component { return target.deltaDecorations(oldIds, [lineDecoration]); } + highlightEditableLines(stickiness, target, range, oldIds = []) { + const lineDecoration = { + range, + options: { + isWholeLine: true, + linesDecorationsClassName: 'myEditableLineDecoration', + className: 'do-not-edit', + stickiness + } + }; + return target.deltaDecorations(oldIds, [lineDecoration]); + } + highlightText(stickiness, target, range, oldIds = []) { const inlineDecoration = { range, @@ -655,6 +692,17 @@ class Editor extends Component { return this.positionsToRange(model, positions); }); + const editableRange = this.positionsToRange(model, [ + editableRegion[0] + 1, + editableRegion[1] - 1 + ]); + + this.data.insideEditDecId = this.highlightEditableLines( + this._monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, + model, + editableRange + ); + // if the forbidden range includes the top of the editor // we simply don't add those decorations if (forbiddenRanges[0][1] > 0) { @@ -743,53 +791,25 @@ class Editor extends Component { }); }; - // Make sure the zone tracks the decoration (i.e. the region) - const handleHintsZoneChange = id => { - const startOfZone = toStartOfLine( - model.getDecorationRange(id) - ).collapseToStart(); - // the decoration needs adjusting if the user creates a line immediately - // before the greyed out region... - const lineOneRange = this.translateRange(startOfZone, -2); - // or immediately after it - const lineTwoRange = this.translateRange(startOfZone, -1); - - for (const lineRange of newLineRanges) { - const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching( - lineRange, - lineOneRange.plusRange(lineTwoRange) - ); - - if (shouldMoveZone) { - this.updateOutputZone(); - } + // Make sure the zone tracks the decoration (i.e. the region), which might + // have changed if a line has been added or removed + const handleHintsZoneChange = () => { + if (newLineRanges.length > 0 || deletedLine > 0) { + this.updateOutputZone(); } }; - // Make sure the zone tracks the decoration (i.e. the region) - const handleDescriptionZoneChange = id => { - const endOfZone = toLastLine( - model.getDecorationRange(id) - ).collapseToStart(); - // the decoration needs adjusting if the user creates a line immediately - // before the editable region. - const lineOneRange = this.translateRange(endOfZone, -1); - - for (const lineRange of newLineRanges) { - const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching( - lineRange, - lineOneRange - ); - - if (shouldMoveZone) { - this.updateViewZone(); - } + // Make sure the zone tracks the decoration (i.e. the region), which might + // have changed if a line has been added or removed + const handleDescriptionZoneChange = () => { + if (newLineRanges.length > 0 || deletedLine > 0) { + this.updateViewZone(); } }; // Stops the greyed out region from covering the editable region. Does not // change the font decoration. - const preventOverlap = id => { + const preventOverlap = (id, stickiness, highlightFunction) => { // Even though the decoration covers the whole line, it has a // startColumn that moves. toStartOfLine ensures that the // comparison detects if any change has occurred on that line @@ -821,17 +841,15 @@ class Editor extends Component { if (touchingDeleted) { // TODO: if they undo this should be reversed - const decorations = this.highlightLines( - this._monaco.editor.TrackedRangeStickiness - .NeverGrowsWhenTypingAtEdges, + const decorations = highlightFunction( + stickiness, model, newCoveringRange, - [id] + id ); this.updateOutputZone(); - // when there's a change, decorations will be [oldId, newId] - return decorations.slice(-1)[0]; + return decorations; } else { return id; } @@ -839,7 +857,17 @@ class Editor extends Component { // we only need to handle the special case of the second region being // pulled up, the first region already behaves correctly. - this.data.endEditDecId = preventOverlap(this.data.endEditDecId); + this.data.endEditDecId = preventOverlap( + this.data.endEditDecId, + this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore, + this.highlightLines + ); + + this.data.insideEditDecId = preventOverlap( + this.data.insideEditDecId, + this._monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, + this.highlightEditableLines + ); // TODO: do the same for the description widget // this has to be handle differently, because we care about the END @@ -847,10 +875,10 @@ class Editor extends Component { // if the editable region includes the first line, the first decoration // will be missing. if (this.data.startEditDecId) { - handleDescriptionZoneChange(this.data.startEditDecId); + handleDescriptionZoneChange(); warnUser(this.data.startEditDecId); } - handleHintsZoneChange(this.data.endEditDecId); + handleHintsZoneChange(); warnUser(this.data.endEditDecId); }); } @@ -889,7 +917,47 @@ class Editor extends Component { } if (this._editor) { - const { output } = this.props; + const { output, tests } = this.props; + const editableRegion = this.getEditableRegion(); + if (this.props.tests !== prevProps.tests && editableRegion.length === 2) { + const challengeComplete = tests.every(test => test.pass && !test.err); + const chellengeHasErrors = tests.some(test => test.err); + + if (challengeComplete) { + let testButton = document.getElementById('test-button'); + testButton.innerHTML = + 'Submit your code and go to next challenge (Ctrl + Enter)'; + testButton.onclick = () => { + const { submitChallenge } = this.props; + submitChallenge(); + }; + + let editableRegionDecorators = document.getElementsByClassName( + 'myEditableLineDecoration' + ); + if (editableRegionDecorators.length > 0) { + for (var i of editableRegionDecorators) { + i.classList.add('tests-passed'); + } + } + document.getElementById('test-output').innerHTML = ''; + document.getElementById('test-status').innerHTML = + '✅ Step completed.'; + } else if (chellengeHasErrors) { + const wordsArray = [ + "Not quite. Here's a hint:", + 'Try again. This might help:', + 'Keep trying. A quick hint for you:', + "You're getting there. This may help:", + "Hang in there. You'll get there. A hint:", + "Don't give up. Here's a hint to get you thinking:" + ]; + document.getElementById('test-status').innerHTML = `✖️ ${ + wordsArray[Math.floor(Math.random() * wordsArray.length)] + }`; + document.getElementById('test-output').innerHTML = `${output[1]}`; + } + } if (this.props.output !== prevProps.output && this._outputNode) { // TODO: output gets wiped when the preview gets updated, keeping the // display is an anti-pattern (the render should not ignore props!). @@ -897,14 +965,6 @@ class Editor extends Component { // (shownHint,maybe) and have that persist through previews. But, for // now: if (output) { - if (output[0]) { - document.getElementById('test-status').innerHTML = output[0]; - } - - if (output[1]) { - document.getElementById('test-output').innerHTML = output[1]; - } - // if either id exists, the editable region exists // TODO: add a layer of abstraction: we should be interacting with // the editable region, not the ids diff --git a/client/src/templates/Challenges/classic/editor.css b/client/src/templates/Challenges/classic/editor.css index 10a8281be6..819fe329cf 100644 --- a/client/src/templates/Challenges/classic/editor.css +++ b/client/src/templates/Challenges/classic/editor.css @@ -6,10 +6,6 @@ color: var(--primary-color); } -[widgetid='my.overlay.widget'] { - padding: 10px; -} - .vs .monaco-scrollable-element > .scrollbar > .slider { z-index: 11; } @@ -17,3 +13,64 @@ .editor-container { background: var(--editor-background); } + +.breadcrumbs-demo { + font-size: 16px; +} + +.breadcrumbs-demo span { + font-weight: bold; +} + +.tabs-row { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.action-row button { + padding: 4px 16px; +} + +.active-tab { + border-color: var(--secondary-color); + background-color: var(--secondary-color); + color: var(--secondary-background); +} + +/* [widgetid='my.overlay.widget'] { + padding: 10px; +} */ + +.editor-upper-jaw, +.editor-lower-jaw { + padding: 15px 15px 15px 0px; +} + +.action-row-container, +.description-container { + background-color: var(--secondary-background); + padding: 10px; + border: 2px solid var(--tertiary-background); + max-width: 700px; +} + +#description p:last-child { + margin: 0px; +} + +#test-status { + padding-bottom: 5px; + padding-top: 5px; +} + +.myEditableLineDecoration { + background-color: var(--gray-45); + width: 15px !important; + margin-left: 5px !important; + margin-right: 5px !important; +} + +.myEditableLineDecoration.tests-passed { + background-color: #4caf50; +} diff --git a/client/src/templates/Challenges/components/BreadCrumb.js b/client/src/templates/Challenges/components/BreadCrumb.js new file mode 100644 index 0000000000..1c7d6c505f --- /dev/null +++ b/client/src/templates/Challenges/components/BreadCrumb.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '../../../components/helpers/index'; + +import './challenge-title.css'; +import i18next from 'i18next'; + +const propTypes = { + block: PropTypes.string, + superBlock: PropTypes.string +}; + +function BreadCrumb({ block, superBlock }) { + return ( +
+ + + {i18next.t(`intro:${superBlock}.title`)} + + +
+ + {i18next.t(`intro:${superBlock}.blocks.${block}.title`)} + +
+ ); +} + +BreadCrumb.displayName = 'BreadCrumb'; +BreadCrumb.propTypes = propTypes; + +export default BreadCrumb; diff --git a/client/src/templates/Challenges/components/Challenge-Title.js b/client/src/templates/Challenges/components/Challenge-Title.js index 21404a4415..2ca72b7406 100644 --- a/client/src/templates/Challenges/components/Challenge-Title.js +++ b/client/src/templates/Challenges/components/Challenge-Title.js @@ -1,10 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from '../../../components/helpers/index'; +import i18next from 'i18next'; import './challenge-title.css'; import GreenPass from '../../../assets/icons/GreenPass'; -import i18next from 'i18next'; +import BreadCrumb from './BreadCrumb'; const propTypes = { block: PropTypes.string, @@ -31,25 +32,7 @@ function ChallengeTitle({ {i18next.t('misc.translation-pending')} )} -
- - - {i18next.t(`intro:${superBlock}.title`)} - - -
- - {i18next.t(`intro:${superBlock}.blocks.${block}.title`)} - -
+
{children}