From fb688e5e71d44f37aea075b425d6f878036d0f84 Mon Sep 17 00:00:00 2001 From: Brooke Date: Wed, 10 Aug 2022 12:09:03 -0700 Subject: [PATCH 01/23] TCA-105 #comment change name of responsive web design cert #time 15m --- client/i18n/locales/english/intro.json | 2 +- tools/challenge-editor/api/configs/superBlockList.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 54380f2e67dc41..982da3d4b41300 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -67,7 +67,7 @@ } }, "2022/responsive-web-design": { - "title": "(New) Responsive Web Design", + "title": "Responsive Web Design", "intro": [ "In this Responsive Web Design Certification, you'll learn the languages that developers use to build webpages: HTML (Hypertext Markup Language) for content, and CSS (Cascading Style Sheets) for design.", "First, you'll build a cat photo app to learn the basics of HTML and CSS. Later, you'll learn modern techniques like CSS variables by building a penguin, and best practices for accessibility by building a web form.", diff --git a/tools/challenge-editor/api/configs/superBlockList.ts b/tools/challenge-editor/api/configs/superBlockList.ts index cb9b1c82bb7086..ffebc33c1f9939 100644 --- a/tools/challenge-editor/api/configs/superBlockList.ts +++ b/tools/challenge-editor/api/configs/superBlockList.ts @@ -48,7 +48,7 @@ export const superBlockList = [ path: '13-relational-databases' }, { - name: '(New) Responsive Web Design', + name: 'Responsive Web Design', path: '14-responsive-web-design-22' }, { From bd4aaf5ea476b6c5c9c7b7d7fe16ff7e9df606fb Mon Sep 17 00:00:00 2001 From: billsedison Date: Thu, 11 Aug 2022 17:37:38 +0800 Subject: [PATCH 02/23] migrate tests for 'build-a-25-5-clock' project to use FCC instead of codepen --- .../build-a-25-5-clock.md | 1152 ++++++++++++++++- 1 file changed, 1149 insertions(+), 3 deletions(-) diff --git a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md index cbb6d71c110c1c..29128537355e2e 100644 --- a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md +++ b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md @@ -1,7 +1,7 @@ --- id: bd7158d8c442eddfaeb5bd0f title: Build a 25 + 5 Clock -challengeType: 3 +challengeType: 14 forumTopicId: 301373 dashedName: build-a-25--5-clock --- @@ -70,9 +70,1155 @@ You can use any mix of HTML, JavaScript, CSS, Bootstrap, SASS, React, Redux, and **User Story #28:** The audio element with id of `beep` must stop playing and be rewound to the beginning when the element with the id of `reset` is clicked. -You can build your project by using this CodePen template and clicking `Save` to create your own pen. Or you can use this CDN link to run the tests in any environment you like: `https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js` +# --hints-- -Once you're done, submit the URL to your working project with all its tests passing. +You should have an element with `id="break-label"` that contains a string (e.g. "Break Length"). + +```js +const breakTitle = document.getElementById('break-label'); +assert.isAbove( + breakTitle.innerText.length, + 0, + 'Element does not contain a string' +); +``` + +You should have an element with `id="session-label"` that contains a string (e.g. "Session Length"). + +```js +const sessionTitle = document.getElementById('session-label'); +assert.isAbove( + sessionTitle.innerText.length, + 0, + 'Element does not contain a string' +); +``` + +You should have two clickable elements with corresponding IDs: `id="break-decrement"` and `id="session-decrement"`. + +```js +assert.isNotNull(document.getElementById('break-decrement')); +assert.isNotNull(document.getElementById('session-decrement')); +``` + +You should have two clickable elements with corresponding IDs: `id="break-increment"` and `id="session-increment"`. + +```js +assert.isNotNull(document.getElementById('break-increment')); +assert.isNotNull(document.getElementById('session-increment')); +``` + +You should have an element with a corresponding `id="break-length"`, which by default (on load) displays a value of 5. + +```js +const breakLength = document.getElementById('break-length'); +assert.strictEqual( + breakLength.nodeName.toLowerCase() === 'input' + ? breakLength.value + : breakLength.innerText, + '5', + 'A value of 5 is not displayed by default' +); +``` + +You should have an element with a corresponding `id="session-length"`, which by default displays a value of 25. + +```js +const sessionLength = document.getElementById('session-length'); +assert.strictEqual( + sessionLength.nodeName.toLowerCase() === 'input' + ? sessionLength.value + : sessionLength.innerText, + '25', + 'A value of 25 is not displayed by default' +); +``` + +You should have an element with a corresponding `id="timer-label"`, that contains a string indicating a session is initialized (e.g. "Session"). + +```js +const timerLabel = document.getElementById('timer-label'); +assert.isAbove( + timerLabel.innerText.length, + 0, + 'Element does not contain a string' +); +``` + +You should have an element with corresponding `id="time-left"`. NOTE: Paused or running, the value in this field should always be displayed in `mm:ss` format (i.e. 25:00). + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const target = document.getElementById('time-left'); + assert.isNotNull(target); + assert.strictEqual( + timerRe.exec(target.innerText)[1], + '25', + 'time-left is not formatted correctly' + ); + // Set session length to 60 + Array(35).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // wait for 1.5 seconds to allow any re-renders to catch up + await timeout(1500); + assert.strictEqual( + timerRe.exec(target.innerText)[1], + '60', + 'time-left is not formatted correctly' + ); +})(); +``` + +You should have a clickable element with a corresponding `id="start_stop"`. + +```js +assert.isNotNull(document.getElementById('start_stop')); +``` + +You should have a clickable element with a corresponding `id="reset"`. + +```js +assert.isNotNull(document.getElementById('reset')); +``` + +When I click the element with the id of `reset`, any running timer should be stopped, the value within `id="break-length"` should return to `5`, the value within `id="session-length"` should return to 25, and the element with `id="time-left"` should reset to its default state. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // decrement session and break length + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + window.setTimeout = savedSetTimeout; + window.setInterval = savedSetInterval; + + // once timer has reached zero wait 1.5 seconds then reset and + // see if every default value is reset + await timeout(1500); + + Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + const timerLabelAfterReset = + document.getElementById('timer-label').innerText; + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const secondsAfterReset = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + // see if timer label changed back + assert.strictEqual( + timerLabelAfterReset, + document.getElementById('timer-label') && + document.getElementById('timer-label').innerText, + 'Default timer label was not properly reset ' + ); + + // wait another 1.5 seconds to be sure value has not changed + // (25 + 5 clock is stopped) + await timeout(1500); + + const breakLengthElement = document.getElementById('break-length'); + assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '5', + 'Default values for break length were not properly reset' + ); + + const sessionLengthElement = document.getElementById('session-length'); + assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '25', + 'Default values for session length were not properly reset' + ); + + const secondsAfterAWhile = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.strictEqual( + secondsAfterAWhile, + secondsAfterReset, + '25 + 5 has paused but time continued elapsing' + ); +})(); +``` + +When I click the element with the id of `break-decrement`, the value within `id="break-length"` decrements by a value of 1, and I can see the updated value. + +```js +Array(4).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const breakLengthElement = document.getElementById('break-length'); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '1' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array.of('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '4' +); +``` + +When I click the element with the id of `break-increment`, the value within `id="break-length"` increments by a value of 1, and I can see the updated value. + +```js +Array(4).fill('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const breakLengthElement = document.getElementById('break-length'); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '8' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array.of('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '6' +); +``` + +When I click the element with the id of `session-decrement`, the value within `id="session-length"` decrements by a value of 1, and I can see the updated value. + +```js +Array(4).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const sessionLengthElement = document.getElementById('session-length'); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '21' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array.of('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '24' +); +``` + +When I click the element with the id of `session-increment`, the value within `id="session-length"` increments by a value of 1, and I can see the updated value. + +```js +Array(4).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const sessionLengthElement = document.getElementById('session-length'); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '28' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array.of('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '26' +); +``` + +I should not be able to set a session or break length to <= 0. + +```js +Array(10).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const breakLengthElement = document.getElementById('break-length'); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '1', + 'Value in element with id of "break-length" is less than 1.' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array(30).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const sessionLengthElement = document.getElementById('session-length'); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '1', + 'Value in element with id of "session-length" is less than 1.' +); +``` + +I should not be able to set a session or break length to > 60. + +```js +Array(60).fill('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const breakLengthElement = document.getElementById('break-length'); +assert.strictEqual( + breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText, + '60', + 'Value in element with id of "break-length" is greater than 60.' +); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +Array(40).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const sessionLengthElement = document.getElementById('session-length'); +assert.strictEqual( + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText, + '60', + 'Value in element with id of "session-length" is greater than 60.' +); +``` + +When I first click the element with `id="start_stop"`, the timer should begin running from the value currently displayed in `id="session-length"`, even if the value has been incremented or decremented from the original value of 25. + +```js +Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); +const sessionLengthElement = document.getElementById('session-length'); +assert.strictEqual( + timerRe.exec(document.getElementById('time-left').innerText)[1], + sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText +); + +// stop the 25 + 5 clock +Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); +``` + +If the timer is running, the element with the id of `time-left` should display the remaining time in `mm:ss` format (decrementing by a value of 1 and updating the display every 1000ms). + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + await timeout(2500); + + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const secondsBefore = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + // wait 1.5 seconds then see if displayed time has changed + // (decremented) + await timeout(1500); + + const secondsAfter = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.isAbove( + +secondsBefore, + +secondsAfter, + '25 + 5 clock has started but time displayed is not changing ' + ); + + // stop the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); +})(); +``` + +If the timer is running and I click the element with `id="start_stop"`, the countdown should pause. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const secondsBefore = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + // wait 1.5 seconds then see if displayed time has changed + await timeout(1500); + + const secondsAfter = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.notStrictEqual( + secondsAfter, + secondsBefore, + '25 + 5 has started but time displayed is not changing' + ); + + // Pause the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait another 1.5 seconds to be sure value has not changed + await timeout(1500); + + const secondsAfterPause = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.strictEqual( + secondsAfterPause, + secondsAfter, + '25 + 5 clock has paused but time continued elapsing' + ); +})(); +``` + +If the timer is paused and I click the element with `id="start_stop"`, the countdown should resume running from the point at which it was paused. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const secondsBefore = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + // wait 1.5 seconds then see if displayed time has changed + await timeout(1500); + + const secondsAfter = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.notStrictEqual( + secondsAfter, + secondsBefore, + '25 + 5 clock has started but time displayed is not changing' + ); + + // Pause the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait another 1.5 seconds to be sure value has not changed + await timeout(1500); + + const secondsAfterPause = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.strictEqual( + secondsAfterPause, + secondsAfter, + '25 + 5 clock has paused but time continued elapsing' + ); + + // Resume the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait another 1.5 seconds to be sure time is decrementing again + await timeout(1500); + + const secondsAfterResume = timerRe.exec( + document.getElementById('time-left').innerText + )[2]; + + assert.isBelow( + +secondsAfterResume, + +secondsAfterPause, + '25 + 5 clock has resumed but displayed time is not changing ' + ); + + // stop the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); +})(); +``` + +When a session countdown reaches zero (NOTE: timer MUST reach 00:00), and a new countdown begins, the element with the id of `timer-label` should display a string indicating a break has begun. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + const timerLabelElement = document.getElementById('timer-label'); + + Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // we decrement session time to the minimum (1 minute) + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + let tLabelA = timerLabelElement.innerHTML; + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + await timeout(1500); + + const breakLengthElement = document.getElementById('break-length'); + const breakLength = +(breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText); + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const breakTime = +timerRe.exec( + document.getElementById('time-left').innerText + )[1]; + assert.isAtMost( + breakTime, + breakLength, + "Break time didn't start with the correct value." + ); + + let tLabelB = timerLabelElement.innerHTML; + + assert.notStrictEqual( + tLabelB, + tLabelA, + "Timer has reached zero but didn't switch to Break time" + ); +})(); +``` + +When a session countdown reaches zero (NOTE: timer MUST reach 00:00), a new break countdown should begin, counting down from the value currently displayed in the `id="break-length"` element. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + const timerLabelElement = document.getElementById('timer-label'); + + Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // we decrement session time to the minimum (1 minute) + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + let tLabelA = timerLabelElement.innerHTML; + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + // wait while timer label switches + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timerLabelElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + const tLabelB = timerLabelElement.innerHTML; + + assert.notStrictEqual( + tLabelB, + tLabelA, + "Timer has reached zero but didn't switch to Break time" + ); + + const breakLengthElement = document.getElementById('break-length'); + const breakLength = +(breakLengthElement.nodeName.toLowerCase() === 'input' + ? breakLengthElement.value + : breakLengthElement.innerText); + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const breakTime = +timerRe.exec( + document.getElementById('time-left').innerText + )[1]; + assert.strictEqual( + breakTime, + breakLength, + "Timer has switched to Break time, but it didn't start with " + + 'the correct value.' + ); + + // stop the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); +})(); +``` + +When a break countdown reaches zero (NOTE: timer MUST reach 00:00), and a new countdown begins, the element with the id of `timer-label` should display a string indicating a session has begun. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + const timerLabelElement = document.getElementById('timer-label'); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // decrement session length and break length to the minimum (1 minute) + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + const breakLabel = timerLabelElement.innerHTML; + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + await timeout(1500); + + const sessionLabel = timerLabelElement.innerHTML; + + assert.notStrictEqual( + sessionLabel, + breakLabel, + "Timer has reached zero but didn't switch back to Session time." + ); + + // stop the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); +})(); +``` + +When a break countdown reaches zero (NOTE: timer MUST reach 00:00), a new session countdown should begin, counting down from the value currently displayed in the `id="session-length"` element. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + const timerLabelElement = document.getElementById('timer-label'); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // decrement session length and break length to the minimum (1 minute) + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + // wait while timer label switches + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timerLabelElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + let tLabelA = timerLabelElement.innerHTML; + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + // wait while timer label switches + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timerLabelElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + const tLabelB = timerLabelElement.innerHTML; + + assert.notStrictEqual( + tLabelB, + tLabelA, + "Timer has reached zero but didn't switch to Session time" + ); + + const sessionLengthElement = document.getElementById('session-length'); + const sessionLength = +(sessionLengthElement.nodeName.toLowerCase() === 'input' + ? sessionLengthElement.value + : sessionLengthElement.innerText); + const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/); + const currentTime = +timerRe.exec( + document.getElementById('time-left').innerText + )[1]; + assert.strictEqual( + currentTime, + sessionLength, + 'Timer has switched back to Session time, but it ' + + "didn't start with the correct value." + ); + + // stop the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); +})(); +``` + +When a countdown reaches zero (NOTE: timer MUST reach 00:00), a sound indicating that time is up should play. This should utilize an HTML5 `audio` tag and have a corresponding `id="beep"`. + +```js +(async () => { + const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + + const timeLeftElement = document.getElementById('time-left'); + + assert.isNotNull( + document.querySelector('audio#beep'), + 'There is no audio tag with ID "beep" on the page.' + ); + + const savedSetTimeout = window.setTimeout; + const savedSetInterval = window.setInterval; + window.setTimeout = (fun) => { + return savedSetTimeout(fun, 30); + }; + window.setInterval = (fun) => { + return savedSetInterval(fun, 30); + }; + + // decrement session time to the minimum (1 minute) + Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + // start the 25 + 5 clock + Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } + }); + + // wait while timer reaches 00:00 + await new Promise((resolve, reject) => { + var observer = new MutationObserver(() => { + if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) { + observer.disconnect(); + resolve(); + } + }); + // pass in the timeLeftElement node, as well as the observer options + observer.observe(timeLeftElement, { + childList: true, + characterData: true, + subtree: true + }); + }); + + await timeout(1500); + + window.setTimeout = savedSetTimeout; + window.setInterval = savedSetInterval; + + await timeout(200); + + assert.isFalse( + document.getElementById('beep').paused, + 'Timer has reached zero but audio is not playing while it should.' + ); +})(); +``` + +The audio element with `id="beep"` must be 1 second or longer. + +```js +(async () => { + const audio = document.querySelector('audio#beep'); + assert.isNotNull( + audio, + 'There is no audio tag with ID "beep" on the page.' + ); + + if (audio.readyState === 0) { + // Wait for the audio to load. + await new Promise((resolve) => { + const listener = audio.addEventListener('loadeddata', () => { + if (audio.readyState > 0) { + audio.removeEventListener('loadeddata', listener); + resolve(); + } + }); + }); + } + + assert.isAbove( + document.getElementById('beep').duration, + 1, + 'Audio element with id="beep" is not at least 1 second long.' + ); +})(); +``` + +The audio element with id of `beep` must stop playing and be rewound to the beginning when the element with the id of `reset` is clicked. + +```js +// Call document.getElementById('beep') each time to overcome framework cache +document.getElementById('beep').play(); +Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => { + if (key && typeof key.click === 'function') { + key.click(); + } +}); + +assert.isTrue( + document.getElementById('beep').paused, + 'Audio element was not stopped when reset was clicked.' +); + +assert.strictEqual( + document.getElementById('beep').currentTime, + 0, + 'Audio element was not rewound when reset was clicked. HINT: use ' + + 'the currentTime property of the audio element to rewind.' +); +``` + +# --seed-- + +## --seed-contents-- + +```html + +``` + +```css + +``` # --solutions-- From b2192c81b6b6cfda0324ef7f25396fd9cfca9682 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 12 Aug 2022 14:16:35 +0300 Subject: [PATCH 03/23] TCA-299 - update editor UI to match the classic view according to figma --- client/src/components/layouts/global.css | 5 +- client/src/components/layouts/learn.css | 4 ++ .../Challenges/classic/action-row.tsx | 42 ++++++++------- .../Challenges/classic/desktop-layout.tsx | 52 +++++++++---------- .../src/templates/Challenges/classic/show.tsx | 11 +++- .../Challenges/components/side-panel.tsx | 2 +- 6 files changed, 67 insertions(+), 49 deletions(-) diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index 1dabb4ee93cee2..68eb41cc25ab24 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -491,7 +491,10 @@ fieldset[disabled] .btn-primary.focus { } } -.button-group .btn:not(:last-child) { +.button-group { + margin-bottom: -10px; +} +.button-group .btn { margin-bottom: 10px; } strong { diff --git a/client/src/components/layouts/learn.css b/client/src/components/layouts/learn.css index a96b2c77b08f52..292b987c9c36fa 100644 --- a/client/src/components/layouts/learn.css +++ b/client/src/components/layouts/learn.css @@ -24,3 +24,7 @@ #learn-app-wrapper .reflex-container.horizontal > .reflex-splitter { height: 5px; } + +#learn-app-wrapper .reflex-container > .reflex-element:first-child:last-child { + flex: 1 1 auto !important; +} diff --git a/client/src/templates/Challenges/classic/action-row.tsx b/client/src/templates/Challenges/classic/action-row.tsx index 8daaaefa04778f..4025f1b69c4443 100644 --- a/client/src/templates/Challenges/classic/action-row.tsx +++ b/client/src/templates/Challenges/classic/action-row.tsx @@ -8,6 +8,7 @@ import EditorTabs from './editor-tabs'; interface ActionRowProps { block: string; hasNotes: boolean; + hasPreview: boolean; isMultifileCertProject: boolean; showInstructions: boolean; showConsole: boolean; @@ -25,6 +26,7 @@ const mapDispatchToProps = { const ActionRow = ({ hasNotes, + hasPreview, togglePane, showNotes, showPreview, @@ -44,17 +46,15 @@ const ActionRow = ({ )}
- {isMultifileCertProject && ( - - )} + + {hasPreview && ( + + )}
); diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index 6fc608c72d7ee3..4bf573240096c7 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -34,6 +34,7 @@ interface DesktopLayoutProps { resizeProps: ResizeProps; superBlock: string; testOutput: ReactElement; + visibleEditors: { [key: string]: boolean }; } const reflexProps = { @@ -85,20 +86,20 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { notes, preview, hasEditableBoundaries, - superBlock + superBlock, + visibleEditors } = props; const challengeFile = getChallengeFile(); const projectBasedChallenge = hasEditableBoundaries; const isMultifileCertProject = challengeType === challengeTypes.multifileCertProject; - const displayPreview = - projectBasedChallenge || isMultifileCertProject - ? showPreview && hasPreview - : hasPreview; + const displayPreview = showPreview && hasPreview; const displayNotes = projectBasedChallenge ? showNotes && hasNotes : false; - const displayConsole = - projectBasedChallenge || isMultifileCertProject ? showConsole : true; + const displayConsole = showConsole; + const displayEditor = Object.entries(visibleEditors).some( + ([, visible]) => visible + ); const { codePane, editorPane, @@ -110,20 +111,19 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { return (
- {(projectBasedChallenge || isMultifileCertProject) && ( - - )} +
{!projectBasedChallenge && showInstructions && ( @@ -131,12 +131,12 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { {instructions} )} - {!projectBasedChallenge && ( + {!projectBasedChallenge && displayEditor && ( )} - - {challengeFile && ( + {challengeFile && displayEditor && ( + { )} - )} - + + )} {(displayPreview || displayConsole) && ( diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index ba98af5b9c5fca..487f20dc2065dc 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -38,6 +38,7 @@ import { challengeMounted, challengeTestsSelector, consoleOutputSelector, + visibleEditorsSelector, createFiles, executeChallenge, initConsole, @@ -58,6 +59,10 @@ import MobileLayout from './mobile-layout'; import './classic.css'; import '../components/test-frame.css'; +type VisibleEditors = { + [key: string]: boolean; +}; + // Redux Setup const mapStateToProps = createStructuredSelector({ challengeFiles: challengeFilesSelector, @@ -65,7 +70,8 @@ const mapStateToProps = createStructuredSelector({ testsRunning: testsRunningSelector, output: consoleOutputSelector, isChallengeCompleted: isChallengeCompletedSelector, - savedChallenges: savedChallengesSelector + savedChallenges: savedChallengesSelector, + visibleEditors: visibleEditorsSelector }); const mapDispatchToProps = (dispatch: Dispatch) => @@ -112,6 +118,7 @@ interface ShowClassicProps { setEditorFocusability: (canFocus: boolean) => void; previewMounted: () => void; savedChallenges: CompletedChallenge[]; + visibleEditors: VisibleEditors; } interface ShowClassicState { @@ -429,6 +436,7 @@ class ShowClassic extends Component { challengeMeta: { nextChallengePath, prevChallengePath } }, challengeFiles, + visibleEditors, t } = this.props; @@ -480,6 +488,7 @@ class ShowClassic extends Component { resizeProps={this.resizeProps} superBlock={superBlock} testOutput={this.renderTestOutput()} + visibleEditors={visibleEditors} /> - {showToolPanel && ( + {showToolPanel && tests.length > 10 && ( Date: Fri, 12 Aug 2022 14:30:06 +0300 Subject: [PATCH 04/23] old style editor: do not show completion modal when using ctrl+enter to submit --- client/src/templates/Challenges/components/Hotkeys.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/templates/Challenges/components/Hotkeys.tsx b/client/src/templates/Challenges/components/Hotkeys.tsx index 3354ff7ed2a21f..d3421294b13b49 100644 --- a/client/src/templates/Challenges/components/Hotkeys.tsx +++ b/client/src/templates/Challenges/components/Hotkeys.tsx @@ -85,7 +85,7 @@ function Hotkeys({ executeChallenge(); } } else { - executeChallenge({ showCompletionModal: true }); + executeChallenge(); } }, focusEditor: (e: React.KeyboardEvent) => { From ab3b7d008f0c28085d8d26245b4199e9c8ced872 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 12 Aug 2022 18:41:36 +0300 Subject: [PATCH 05/23] TCA-299 - improve UI for old style editor --- .../formHelpers/block-save-button.tsx | 2 +- .../components/formHelpers/form-fields.tsx | 51 +++++----- client/src/components/formHelpers/form.tsx | 17 ++-- client/src/components/layouts/global.css | 27 ++++++ client/src/components/layouts/prism.css | 10 ++ client/src/components/layouts/variables.css | 2 + .../templates/Challenges/classic/editor.tsx | 2 +- .../Challenges/classic/simple-editor.tsx | 2 +- .../Challenges/components/challenge-title.tsx | 5 - .../Challenges/components/tool-panel.tsx | 2 +- .../Challenges/projects/frontend/Show.tsx | 50 +++++----- .../Challenges/projects/solution-form.tsx | 8 +- .../src/templates/Challenges/video/Show.tsx | 92 ++++++++++--------- .../src/templates/Challenges/video/show.css | 69 +++++++++++--- 14 files changed, 209 insertions(+), 130 deletions(-) diff --git a/client/src/components/formHelpers/block-save-button.tsx b/client/src/components/formHelpers/block-save-button.tsx index 1a57838e3c2945..2adad66ffd39c1 100644 --- a/client/src/components/formHelpers/block-save-button.tsx +++ b/client/src/components/formHelpers/block-save-button.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; function BlockSaveButton(props?: Record): JSX.Element { const { t } = useTranslation(); return ( - ); diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx index 4b43881894d956..edb8a8e65ac294 100644 --- a/client/src/components/formHelpers/form-fields.tsx +++ b/client/src/components/formHelpers/form-fields.tsx @@ -1,6 +1,5 @@ import { Alert, - Col, ControlLabel, FormControl, FormGroup, @@ -72,7 +71,7 @@ function FormFields(props: FormFieldsProps): JSX.Element { ) : null; }; return ( -
+ <> {formFields .filter(formField => !ignored.includes(formField.name)) .map(({ name, label }) => ( @@ -85,35 +84,33 @@ function FormFields(props: FormFieldsProps): JSX.Element { name in placeholders ? placeholders[name] : ''; const isURL = types[name] === 'url'; return ( - - - {type === 'hidden' ? null : ( - {label} - )} - - {nullOrWarning( - value as string, - !pristine && error, - isURL, - name - )} - - + + {type === 'hidden' ? null : ( + {label} + )} + + {nullOrWarning( + value as string, + !pristine && error, + isURL, + name + )} + ); }} ))} -
+ ); } diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx index 3b9f800c2a1009..c136bd73efcde9 100644 --- a/client/src/components/formHelpers/form.tsx +++ b/client/src/components/formHelpers/form.tsx @@ -6,7 +6,6 @@ import { ValidatedValues, FormFields, BlockSaveButton, - BlockSaveWrapper, formatUrlValues } from '../formHelpers/index'; @@ -54,15 +53,13 @@ function DynamicForm({ style={{ width: '100%' }} > - - {hideButton ? null : ( - - {buttonText ? buttonText : null} - - )} - + {!hideButton && ( + + {buttonText ? buttonText : null} + + )} )} diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index 68eb41cc25ab24..038fc6c2f4c5bb 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -501,6 +501,33 @@ strong { color: var(--secondary-color); } +.form-group.embedded { + border: 1px solid var(--tc-black-40); + padding: 8px 10px 2px; + border-radius: 4px; + position: relative; + max-width: 320px; +} + +.form-group.embedded .control-label { + display: block; + font-size: 11px; + font-family: 'Roboto'; + line-height: 10px; + color: var(--tc-turq-160); + margin-bottom: 4px; +} + +.form-group.embedded .form-control { + border: 0 none; + padding: 0; + height: 22px; + font-size: 14px; + line-height: 22px; + font-family: 'Roboto'; + color: var(--tc-black-100); +} + .form-control { color: var(--primary-color); outline: none; diff --git a/client/src/components/layouts/prism.css b/client/src/components/layouts/prism.css index df0b319f65d4ca..df045354a0089b 100644 --- a/client/src/components/layouts/prism.css +++ b/client/src/components/layouts/prism.css @@ -1,3 +1,9 @@ +pre[class*="language-"].line-numbers.line-numbers { + border-radius: 8px; + font-size: 14px; + line-height: 22px; +} + code .token.operator { background: none; } @@ -7,6 +13,10 @@ pre[class*='language-'] { background: var(--primary-background); } +.line-numbers > p { + color: var(--tc-black-100); +} + .default pre[class*='language-']::selection, .default pre[class*='language-'] ::selection, .default code[class*='language-']::selection, diff --git a/client/src/components/layouts/variables.css b/client/src/components/layouts/variables.css index 294bc1799e77a0..5f7b45774e5367 100644 --- a/client/src/components/layouts/variables.css +++ b/client/src/components/layouts/variables.css @@ -37,6 +37,8 @@ --tc-link-blue-light: #5fb7ee; --tc-black: #000; --tc-black-100: #2a2a2a; + --tc-black-60: #7F7F7F; + --tc-black-40: #aaaaaa; --tc-black-20: #d4d4d4; --tc-black-10: #e9e9e9; --tc-black-5: #f4f4f4; diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index be63d9783a9381..ba1a69ae3002a6 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -573,7 +573,7 @@ const Editor = (props: EditorProps): JSX.Element => { attemptRef.current.attempts++; } - const tryToSubmitChallenge = debounce(props.submitChallenge, 2000); + const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, {leading: true}); function createLowerJaw(outputNode: HTMLElement, callback?: () => void) { const { output } = props; diff --git a/client/src/templates/Challenges/classic/simple-editor.tsx b/client/src/templates/Challenges/classic/simple-editor.tsx index 63f5399c80b887..abafe4feb36d10 100755 --- a/client/src/templates/Challenges/classic/simple-editor.tsx +++ b/client/src/templates/Challenges/classic/simple-editor.tsx @@ -513,7 +513,7 @@ const SimpleEditor = (props: EditorProps): JSX.Element => { attemptRef.current.attempts++; } - const tryToSubmitChallenge = debounce(props.submitChallenge, 2000); + const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, {leading: true}); function resetMarginDecorations() { const { model, insideEditDecId } = dataRef.current; diff --git a/client/src/templates/Challenges/components/challenge-title.tsx b/client/src/templates/Challenges/components/challenge-title.tsx index f1ecf699a63023..97453deb051dbe 100644 --- a/client/src/templates/Challenges/components/challenge-title.tsx +++ b/client/src/templates/Challenges/components/challenge-title.tsx @@ -2,7 +2,6 @@ import i18next from 'i18next'; import React from 'react'; import GreenPass from '../../../assets/icons/green-pass'; import { Link } from '../../../components/helpers/index'; -import BreadCrumb from './bread-crumb'; import './challenge-title.css'; @@ -16,11 +15,8 @@ interface ChallengeTitleProps { } function ChallengeTitle({ - block, children, isCompleted, - showBreadCrumbs = true, - superBlock, translationPending }: ChallengeTitleProps): JSX.Element { return ( @@ -36,7 +32,6 @@ function ChallengeTitle({ )} - {showBreadCrumbs && }

{children}

diff --git a/client/src/templates/Challenges/components/tool-panel.tsx b/client/src/templates/Challenges/components/tool-panel.tsx index 371973da6a2a18..0195124909ee8a 100644 --- a/client/src/templates/Challenges/components/tool-panel.tsx +++ b/client/src/templates/Challenges/components/tool-panel.tsx @@ -74,7 +74,7 @@ function ToolPanel({ const { t } = useTranslation(); - const tryToSubmitChallenge = debounce(submitChallenge, 2000); + const tryToSubmitChallenge = debounce(submitChallenge, 2000, {leading: true}); return (
bindActionCreators( { + submitChallenge, updateChallengeMeta, challengeMounted, updateSolutionFormValues, - openCompletionModal: () => openModal('completion') }, dispatch ); @@ -51,7 +47,7 @@ interface ProjectProps { challengeMounted: (arg0: string) => void; data: { challengeNode: ChallengeNode }; isChallengeCompleted: boolean; - openCompletionModal: () => void; + submitChallenge: () => void; pageContext: { challengeMeta: ChallengeMeta; }; @@ -60,13 +56,24 @@ interface ProjectProps { updateSolutionFormValues: () => void; } +interface ProjectState { + completed: boolean; + hasErrors: boolean; +} + // Component -class Project extends Component { +class Project extends Component { static displayName: string; private _container: HTMLElement | null = null; constructor(props: ProjectProps) { super(props); + + this.state = { + completed: false, + hasErrors: false, + }; + this.handleSubmit = this.handleSubmit.bind(this); } componentDidMount() { @@ -120,12 +127,15 @@ class Project extends Component { } handleSubmit({ - showCompletionModal + completed }: { - showCompletionModal: boolean; + completed: boolean; }): void { - if (showCompletionModal) { - this.props.openCompletionModal(); + this.setState({completed, hasErrors: !completed}); + + const { submitChallenge } = this.props; + if (completed) { + submitChallenge(); } } @@ -135,13 +145,10 @@ class Project extends Component { challengeNode: { challenge: { challengeType, - fields: { blockName }, - forumTopicId, title, description, instructions, superBlock, - certification, block, translationPending } @@ -192,19 +199,8 @@ class Project extends Component { onSubmit={this.handleSubmit} updateSolutionForm={updateSolutionFormValues} /> - -
- - diff --git a/client/src/templates/Challenges/projects/solution-form.tsx b/client/src/templates/Challenges/projects/solution-form.tsx index b5a43cce74986a..712081077fb0a0 100644 --- a/client/src/templates/Challenges/projects/solution-form.tsx +++ b/client/src/templates/Challenges/projects/solution-form.tsx @@ -12,7 +12,7 @@ import { import { Form, ValidatedValues } from '../../../components/formHelpers'; interface SubmitProps { - showCompletionModal: boolean; + completed: boolean; } interface FormProps extends WithTranslation { @@ -38,9 +38,9 @@ export class SolutionForm extends Component { // updates values on store this.props.updateSolutionForm(validatedValues.values); if (validatedValues.invalidValues.length === 0) { - this.props.onSubmit({ showCompletionModal: true }); + this.props.onSubmit({ completed: true }); } else { - this.props.onSubmit({ showCompletionModal: false }); + this.props.onSubmit({ completed: false }); } } }; @@ -57,7 +57,7 @@ export class SolutionForm extends Component { { name: 'githubLink', label: t('learn.github-link') } ]; - const buttonCopy = t('learn.i-completed'); + const buttonCopy = t('learn.submit-and-go'); const options = { types: { diff --git a/client/src/templates/Challenges/video/Show.tsx b/client/src/templates/Challenges/video/Show.tsx index ecf7bda07b9190..b414ace77f6a81 100644 --- a/client/src/templates/Challenges/video/Show.tsx +++ b/client/src/templates/Challenges/video/Show.tsx @@ -11,6 +11,8 @@ import type { Dispatch } from 'redux'; import { createSelector } from 'reselect'; // Local Utilities +import Fail from '../../../assets/icons/test-fail'; +import GreenPass from '../../../assets/icons/test-pass'; import Loader from '../../../components/helpers/loader'; import Spacer from '../../../components/helpers/spacer'; import LearnLayout from '../../../components/layouts/learn'; @@ -18,14 +20,13 @@ import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types'; import ChallengeDescription from '../components/Challenge-Description'; import Hotkeys from '../components/Hotkeys'; import VideoPlayer from '../components/VideoPlayer'; -import ChallengeTitle from '../components/challenge-title'; import CompletionModal from '../components/completion-modal'; import PrismFormatted from '../components/prism-formatted'; import { isChallengeCompletedSelector, challengeMounted, updateChallengeMeta, - openModal, + submitChallenge, updateSolutionFormValues } from '../redux'; @@ -42,10 +43,10 @@ const mapStateToProps = createSelector( const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { + submitChallenge, updateChallengeMeta, challengeMounted, updateSolutionFormValues, - openCompletionModal: () => openModal('completion') }, dispatch ); @@ -56,16 +57,17 @@ interface ShowVideoProps { data: { challengeNode: ChallengeNode }; description: string; isChallengeCompleted: boolean; - openCompletionModal: () => void; pageContext: { challengeMeta: ChallengeMeta; }; + submitChallenge: () => void; t: TFunction; updateChallengeMeta: (arg0: ChallengeMeta) => void; updateSolutionFormValues: () => void; } interface ShowVideoState { + completed: boolean; subtitles: string; downloadURL: string | null; selectedOption: number | null; @@ -87,7 +89,8 @@ class ShowVideo extends Component { selectedOption: null, answer: 1, showWrong: false, - videoIsLoaded: false + videoIsLoaded: false, + completed: false }; this.handleSubmit = this.handleSubmit.bind(this); @@ -143,12 +146,12 @@ class ShowVideo extends Component { } } - handleSubmit(solution: number, openCompletionModal: () => void) { + handleSubmit(solution: number) { if (solution - 1 === this.state.selectedOption) { this.setState({ + completed: true, showWrong: false }); - openCompletionModal(); } else { this.setState({ showWrong: true @@ -190,11 +193,11 @@ class ShowVideo extends Component { } } }, - openCompletionModal, pageContext: { challengeMeta: { nextChallengePath, prevChallengePath } }, t, + submitChallenge, isChallengeCompleted } = this.props; @@ -203,9 +206,7 @@ class ShowVideo extends Component { )} - ${title}`; return ( { - this.handleSubmit(solution, openCompletionModal); - }} + executeChallenge={() => this.handleSubmit(solution)} innerRef={(c: HTMLElement | null) => (this._container = c)} nextChallengePath={nextChallengePath} prevChallengePath={prevChallengePath} @@ -216,16 +217,6 @@ class ShowVideo extends Component { /> - - - {title} - -
{!this.state.videoIsLoaded ? ( @@ -243,9 +234,15 @@ class ShowVideo extends Component { />
+ +

+ Question +

+ + - +
@@ -276,28 +273,41 @@ class ShowVideo extends Component {
-
- {this.state.showWrong ? ( - {t('learn.wrong-answer')} - ) : ( +
+ {this.state.showWrong && ( + + + {t('learn.wrong-answer')} + + )} + {this.state.completed && ( + + + Great Job! + + )} + + {!this.state.completed && !this.state.showWrong && ( {t('learn.check-answer')} )}
- - + {this.state.completed ? ( + + ) : ( + + )} label { margin: 0; - align-items: center; + align-items: flex-start; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--quaternary-background) var(--secondary-background); @@ -54,31 +54,28 @@ } .video-quiz-option-label { - padding: 20px; + padding: 10px 0; cursor: pointer; display: flex; font-weight: normal; - border-left: 4px solid var(--tertiary-background); - border-right: 4px solid var(--tertiary-background); - border-top: 2px solid var(--tertiary-background); - border-bottom: 2px solid var(--tertiary-background); } .video-quiz-option-label:first-child { - border-top: 4px solid var(--tertiary-background); + padding-top: 0; } .video-quiz-option-label:last-child { - border-bottom: 4px solid var(--tertiary-background); + padding-bottom: 0; } + .video-quiz-input-hidden { position: absolute; left: -9999px; } .video-quiz-input-visible { - margin-right: 15px; + margin-right: 8px; position: relative; top: 2px; display: inline-block; @@ -87,8 +84,12 @@ max-width: 20px; max-height: 20px; border-radius: 50%; - background-color: var(--secondary-background); - border: 2px solid var(--primary-color); + background-color: var(--tc-white); + border: 1px solid var(--tc-black-60); +} + +.video-quiz-input-visible:not(:empty) { + border-color: var(--tc-turq-160); } .video-quiz-selected-input { @@ -97,7 +98,7 @@ position: absolute; top: 50%; left: 50%; - background-color: var(--primary-color); + background-color: var(--tc-turq-160); border-radius: 50%; transform: translate(-50%, -50%); } @@ -114,3 +115,47 @@ /* remove default prism background */ background: none; } + +.challenge-instructions { + color: var(--tc-black-100); +} + +.challenge-instructions:not(:empty) { + margin-bottom: 18px; +} + +.video-description .line-numbers > p:first-child { + font-family: 'Roboto'; + font-weight: bold; + line-height: 26px; + font-size: 20px; + color: var(--tc-black-100); +} + +.video-quiz-cta-text { + font-size: 16px; + line-height: 24px; + font-family: 'Roboto'; + color: var(--tc-black-100); + margin-bottom: 24px; +} + +.video-quiz-cta-text > span { + display: flex; + align-items: center; + gap: 12px; +} + +.video-section-label { + font-size: 18px; + font-weight: 600; + font-family: 'Barlow', 'sans-serif'; + line-height: 22px; + color: var(--tc-black-100); + text-transform: uppercase; + + border-top: 1px solid var(--tc-black-10); + margin-top: 32px; + padding-top: 18px; + margin-bottom: 24px; +} From f68c31ab574d33409e977df0a681a423b3596134 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 15 Aug 2022 11:32:22 +0300 Subject: [PATCH 06/23] TCA-299 - handle lint fixes --- .../components/formHelpers/form-fields.tsx | 2 +- client/src/components/layouts/prism.css | 2 +- client/src/components/layouts/variables.css | 2 +- .../Challenges/classic/action-row.tsx | 1 - .../templates/Challenges/classic/editor.tsx | 4 +++- .../Challenges/classic/simple-editor.tsx | 4 +++- .../templates/Challenges/codeally/show.tsx | 15 ++++++------ .../Challenges/components/tool-panel.tsx | 4 +++- .../Challenges/projects/backend/Show.tsx | 8 ++----- .../Challenges/projects/frontend/Show.tsx | 14 ++++------- .../src/templates/Challenges/video/Show.tsx | 24 ++++++++++++------- .../src/templates/Challenges/video/show.css | 1 - 12 files changed, 41 insertions(+), 40 deletions(-) diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx index edb8a8e65ac294..a34fd4c669f1c6 100644 --- a/client/src/components/formHelpers/form-fields.tsx +++ b/client/src/components/formHelpers/form-fields.tsx @@ -84,7 +84,7 @@ function FormFields(props: FormFieldsProps): JSX.Element { name in placeholders ? placeholders[name] : ''; const isURL = types[name] === 'url'; return ( - + {type === 'hidden' ? null : ( {label} )} diff --git a/client/src/components/layouts/prism.css b/client/src/components/layouts/prism.css index df045354a0089b..5a165b2581bdd6 100644 --- a/client/src/components/layouts/prism.css +++ b/client/src/components/layouts/prism.css @@ -1,4 +1,4 @@ -pre[class*="language-"].line-numbers.line-numbers { +pre[class*='language-'].line-numbers.line-numbers { border-radius: 8px; font-size: 14px; line-height: 22px; diff --git a/client/src/components/layouts/variables.css b/client/src/components/layouts/variables.css index 5f7b45774e5367..2d757df343facc 100644 --- a/client/src/components/layouts/variables.css +++ b/client/src/components/layouts/variables.css @@ -37,7 +37,7 @@ --tc-link-blue-light: #5fb7ee; --tc-black: #000; --tc-black-100: #2a2a2a; - --tc-black-60: #7F7F7F; + --tc-black-60: #7f7f7f; --tc-black-40: #aaaaaa; --tc-black-20: #d4d4d4; --tc-black-10: #e9e9e9; diff --git a/client/src/templates/Challenges/classic/action-row.tsx b/client/src/templates/Challenges/classic/action-row.tsx index 4025f1b69c4443..e90562294d767a 100644 --- a/client/src/templates/Challenges/classic/action-row.tsx +++ b/client/src/templates/Challenges/classic/action-row.tsx @@ -30,7 +30,6 @@ const ActionRow = ({ togglePane, showNotes, showPreview, - isMultifileCertProject, showInstructions, showConsole, superBlock, diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index ba1a69ae3002a6..58174c3309e202 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -573,7 +573,9 @@ const Editor = (props: EditorProps): JSX.Element => { attemptRef.current.attempts++; } - const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, {leading: true}); + const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, { + leading: true + }); function createLowerJaw(outputNode: HTMLElement, callback?: () => void) { const { output } = props; diff --git a/client/src/templates/Challenges/classic/simple-editor.tsx b/client/src/templates/Challenges/classic/simple-editor.tsx index abafe4feb36d10..1049bcf8e05f8b 100755 --- a/client/src/templates/Challenges/classic/simple-editor.tsx +++ b/client/src/templates/Challenges/classic/simple-editor.tsx @@ -513,7 +513,9 @@ const SimpleEditor = (props: EditorProps): JSX.Element => { attemptRef.current.attempts++; } - const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, {leading: true}); + const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, { + leading: true + }); function resetMarginDecorations() { const { model, insideEditDecId } = dataRef.current; diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx index 6fd66637a15a8b..1a5c33315b13d5 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -33,6 +33,7 @@ import { isChallengeCompletedSelector, updateChallengeMeta, openModal, + submitChallenge, updateSolutionFormValues } from '../redux'; import { createFlashMessage } from '../../../components/Flash/redux'; @@ -76,6 +77,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { challengeMounted, + submitChallenge, createFlashMessage, hideCodeAlly, openCompletionModal: () => openModal('completion'), @@ -93,6 +95,7 @@ interface ShowCodeAllyProps { createFlashMessage: typeof createFlashMessage; data: { challengeNode: ChallengeNode }; hideCodeAlly: () => void; + submitChallenge: () => void; isChallengeCompleted: boolean; isSignedIn: boolean; openCompletionModal: () => void; @@ -138,11 +141,7 @@ class ShowCodeAlly extends Component { this.props.hideCodeAlly(); } - handleSubmit = ({ - showCompletionModal - }: { - showCompletionModal: boolean; - }) => { + handleSubmit = ({ completed }: { completed: boolean }) => { const { completedChallenges, createFlashMessage, @@ -151,7 +150,7 @@ class ShowCodeAlly extends Component { challenge: { id: challengeId } } }, - openCompletionModal, + submitChallenge, partiallyCompletedChallenges } = this.props; @@ -168,8 +167,8 @@ class ShowCodeAlly extends Component { type: 'danger', message: FlashMessages.CompleteProjectFirst }); - } else if (showCompletionModal) { - openCompletionModal(); + } else if (completed) { + submitChallenge(); } }; diff --git a/client/src/templates/Challenges/components/tool-panel.tsx b/client/src/templates/Challenges/components/tool-panel.tsx index 0195124909ee8a..47afd0ed8256d1 100644 --- a/client/src/templates/Challenges/components/tool-panel.tsx +++ b/client/src/templates/Challenges/components/tool-panel.tsx @@ -74,7 +74,9 @@ function ToolPanel({ const { t } = useTranslation(); - const tryToSubmitChallenge = debounce(submitChallenge, 2000, {leading: true}); + const tryToSubmitChallenge = debounce(submitChallenge, 2000, { + leading: true + }); return (
{ challengeMounted(challengeMeta.id); } - handleSubmit({ - showCompletionModal - }: { - showCompletionModal: boolean; - }): void { + handleSubmit(): void { this.props.executeChallenge({ - showCompletionModal + showCompletionModal: false }); } diff --git a/client/src/templates/Challenges/projects/frontend/Show.tsx b/client/src/templates/Challenges/projects/frontend/Show.tsx index 5e559f76f5fb0a..a7e8dccf6dd29a 100644 --- a/client/src/templates/Challenges/projects/frontend/Show.tsx +++ b/client/src/templates/Challenges/projects/frontend/Show.tsx @@ -1,4 +1,4 @@ -import { Button, Grid, Col, Row } from '@freecodecamp/react-bootstrap'; +import { Grid, Col, Row } from '@freecodecamp/react-bootstrap'; import { graphql } from 'gatsby'; import React, { Component } from 'react'; import Helmet from 'react-helmet'; @@ -37,7 +37,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => submitChallenge, updateChallengeMeta, challengeMounted, - updateSolutionFormValues, + updateSolutionFormValues }, dispatch ); @@ -71,7 +71,7 @@ class Project extends Component { this.state = { completed: false, - hasErrors: false, + hasErrors: false }; this.handleSubmit = this.handleSubmit.bind(this); @@ -126,12 +126,8 @@ class Project extends Component { } } - handleSubmit({ - completed - }: { - completed: boolean; - }): void { - this.setState({completed, hasErrors: !completed}); + handleSubmit({ completed }: { completed: boolean }): void { + this.setState({ completed, hasErrors: !completed }); const { submitChallenge } = this.props; if (completed) { diff --git a/client/src/templates/Challenges/video/Show.tsx b/client/src/templates/Challenges/video/Show.tsx index b414ace77f6a81..d95b72a2026502 100644 --- a/client/src/templates/Challenges/video/Show.tsx +++ b/client/src/templates/Challenges/video/Show.tsx @@ -46,7 +46,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => submitChallenge, updateChallengeMeta, challengeMounted, - updateSolutionFormValues, + updateSolutionFormValues }, dispatch ); @@ -185,7 +185,6 @@ class ShowVideo extends Component { superBlock, certification, block, - translationPending, videoId, videoLocaleIds, bilibiliIds, @@ -197,8 +196,7 @@ class ShowVideo extends Component { challengeMeta: { nextChallengePath, prevChallengePath } }, t, - submitChallenge, - isChallengeCompleted + submitChallenge } = this.props; const blockNameTitle = `${t( @@ -236,13 +234,21 @@ class ShowVideo extends Component { -

- Question -

+

Question

- + - +
diff --git a/client/src/templates/Challenges/video/show.css b/client/src/templates/Challenges/video/show.css index 577c462fdcd6ad..9fe0605dfe4376 100644 --- a/client/src/templates/Challenges/video/show.css +++ b/client/src/templates/Challenges/video/show.css @@ -68,7 +68,6 @@ padding-bottom: 0; } - .video-quiz-input-hidden { position: absolute; left: -9999px; From 4bda5102bb413d99a2203bcd3e1a415d3f730f4d Mon Sep 17 00:00:00 2001 From: Brooke Date: Tue, 23 Aug 2022 16:50:22 -0700 Subject: [PATCH 07/23] TCA-355 #comment This PR handles clicks w/in the FCC app so that they are redirected to a new window. Also removing prettier bc it sucks. #time 4h --- .../src/components/layouts/tc-integration.tsx | 34 +++++++++++++++++++ package.json | 1 - 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index 6f1703ac4d299b..23f6e845c1bbc9 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -97,6 +97,7 @@ class TcIntegrationLayout extends Component { window.addEventListener('online', this.updateOnlineStatus); window.addEventListener('offline', this.updateOnlineStatus); + window.addEventListener('click', this.externalLinkHandler); } componentDidUpdate(prevProps: TcIntegrationLayoutProps) { @@ -112,6 +113,39 @@ class TcIntegrationLayout extends Component { window.removeEventListener('offline', this.updateOnlineStatus); } + externalLinkHandler = (event: MouseEvent) => { + // if we're not clicking an anchor tag, there's nothing to do + const eventTarget = event.target as HTMLElement; + if (eventTarget?.localName !== 'a') { + return; + } + + // if the target of the click isn't external, there's nothing to do + const target = eventTarget as HTMLAnchorElement; + const url = new URL(target.href); + if (url.host === window.location.host) { + return; + } + + // stop the click so we can alter it + event.stopPropagation(); + event.preventDefault(); + + // if this is a freecodecamp lesson, change its domain and path + if (url.host === 'learn.freecodecamp.org') { + url.host = window.location.host; + // TODO: it would be nice to not require that the FCC + // app knows about the paths in the platform UI, but + // creating a way to share this info would be complex and + // time consuming, so we can handle it when we get another + // provider. + url.pathname = `learn/freecodecamp/${url.pathname}`; + } + + // now open the url in a new tab + window.open(url, '_blank'); + }; + updateOnlineStatus = () => { const { onlineStatusChange } = this.props; const isOnline = diff --git a/package.json b/package.json index 7f0ec026a9fe46..c0e4e47f341bdb 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "lint:challenges": "cd ./curriculum && npm run lint", "lint:js": "eslint --max-warnings 0 .", "lint:ts": "tsc && tsc -p config && tsc -p tools/ui-components && tsc -p utils", - "lint:prettier": "prettier --list-different .", "seed": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser", "seed:certified-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser certUser", "serve:client": "cd ./client && npm run serve", From 162eb27edca47f44988221057d6681945261bd52 Mon Sep 17 00:00:00 2001 From: Brooke Date: Tue, 23 Aug 2022 17:07:46 -0700 Subject: [PATCH 08/23] TCA-355 fix prettier crap --- client/src/components/layouts/tc-integration.tsx | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index 23f6e845c1bbc9..e495dbf7d03041 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -114,6 +114,8 @@ class TcIntegrationLayout extends Component { } externalLinkHandler = (event: MouseEvent) => { + // prettier sucks + // if we're not clicking an anchor tag, there's nothing to do const eventTarget = event.target as HTMLElement; if (eventTarget?.localName !== 'a') { diff --git a/package.json b/package.json index c0e4e47f341bdb..7f0ec026a9fe46 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "lint:challenges": "cd ./curriculum && npm run lint", "lint:js": "eslint --max-warnings 0 .", "lint:ts": "tsc && tsc -p config && tsc -p tools/ui-components && tsc -p utils", + "lint:prettier": "prettier --list-different .", "seed": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser", "seed:certified-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser certUser", "serve:client": "cd ./client && npm run serve", From 2bb2f8356d6dff87c49083f491840c8354699071 Mon Sep 17 00:00:00 2001 From: Brooke Date: Tue, 23 Aug 2022 17:10:04 -0700 Subject: [PATCH 09/23] TCA-355 change comment --- client/src/components/layouts/tc-integration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index e495dbf7d03041..9d9a39c48cf3e0 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -114,7 +114,7 @@ class TcIntegrationLayout extends Component { } externalLinkHandler = (event: MouseEvent) => { - // prettier sucks + // prettier is the worst // if we're not clicking an anchor tag, there's nothing to do const eventTarget = event.target as HTMLElement; From c871ab34dcb42fc2b108a639a36fee2d775d0e88 Mon Sep 17 00:00:00 2001 From: Brooke Date: Wed, 24 Aug 2022 09:18:14 -0700 Subject: [PATCH 10/23] TCA-355 #comment This commit fixes an issue w/FCC internal URLS #time 5m --- client/src/components/layouts/tc-integration.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index 9d9a39c48cf3e0..69515d7a512859 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -135,13 +135,13 @@ class TcIntegrationLayout extends Component { // if this is a freecodecamp lesson, change its domain and path if (url.host === 'learn.freecodecamp.org') { - url.host = window.location.host; + url.host = new URL(document.referrer).host; // TODO: it would be nice to not require that the FCC // app knows about the paths in the platform UI, but // creating a way to share this info would be complex and // time consuming, so we can handle it when we get another // provider. - url.pathname = `learn/freecodecamp/${url.pathname}`; + url.pathname = `learn/freecodecamp${url.pathname}`; } // now open the url in a new tab From 8d7fc7edf20c8d24634c388b979d218331aab3bf Mon Sep 17 00:00:00 2001 From: Brooke Date: Wed, 24 Aug 2022 13:47:04 -0700 Subject: [PATCH 11/23] TCA-325 #comment This commit removes the Reset button from tool panel #time15m --- client/src/templates/Challenges/components/tool-panel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/templates/Challenges/components/tool-panel.tsx b/client/src/templates/Challenges/components/tool-panel.tsx index 47afd0ed8256d1..341878554b5970 100644 --- a/client/src/templates/Challenges/components/tool-panel.tsx +++ b/client/src/templates/Challenges/components/tool-panel.tsx @@ -64,7 +64,7 @@ function ToolPanel({ isMobile, isSignedIn, isRunningTests, - openResetModal, + // openResetModal, challengeIsCompleted, submitChallenge }: ToolPanelProps) { @@ -110,11 +110,11 @@ function ToolPanel({ {isMobile ? t('buttons.save') : t('buttons.save-code')} )} - {challengeType !== challengeTypes.multifileCertProject && ( + {/* {challengeType !== challengeTypes.multifileCertProject && ( - )} + )} */}
); } From ea95a3e4b8255baa66489336341fc9ca278c96fc Mon Sep 17 00:00:00 2001 From: Brooke Date: Wed, 24 Aug 2022 13:57:39 -0700 Subject: [PATCH 12/23] TCA-327 #comment This commit adds the lesson title to the old editor #time 15m --- client/src/templates/Challenges/classic/show.tsx | 1 + .../Challenges/components/Challenge-Description.tsx | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 487f20dc2065dc..f80c3dcdef5de7 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -341,6 +341,7 @@ class ShowClassic extends Component { block={block} description={description} instructions={instructions} + title={title} /> } guideUrl={getGuideUrl({ forumTopicId, title })} diff --git a/client/src/templates/Challenges/components/Challenge-Description.tsx b/client/src/templates/Challenges/components/Challenge-Description.tsx index 59a15a3c7252b5..af6e6767f9396c 100644 --- a/client/src/templates/Challenges/components/Challenge-Description.tsx +++ b/client/src/templates/Challenges/components/Challenge-Description.tsx @@ -7,6 +7,7 @@ type Challenge = { block?: string; description?: string; instructions?: string; + title?: string; }; function ChallengeDescription(challenge: Challenge): JSX.Element { @@ -16,6 +17,12 @@ function ChallengeDescription(challenge: Challenge): JSX.Element { challenge.block ? ' ' + challenge.block : '' }`} > + {challenge.title && ( + <> + +
+ + )} {challenge.description && } {challenge.instructions && ( <> From 583442c48fbf46a3fa3ecdafc64a671e3d02ea13 Mon Sep 17 00:00:00 2001 From: Brooke Date: Thu, 25 Aug 2022 10:04:01 -0700 Subject: [PATCH 13/23] TCA-354 #comment This PR handles a 2nd flavor of learn URL for FCC exernal links #time 30m --- .../src/components/layouts/tc-integration.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index 69515d7a512859..d7211792668391 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -118,12 +118,13 @@ class TcIntegrationLayout extends Component { // if we're not clicking an anchor tag, there's nothing to do const eventTarget = event.target as HTMLElement; - if (eventTarget?.localName !== 'a') { + const anchorTag = eventTarget.closest('a'); + if (!anchorTag) { return; } // if the target of the click isn't external, there's nothing to do - const target = eventTarget as HTMLAnchorElement; + const target = anchorTag; const url = new URL(target.href); if (url.host === window.location.host) { return; @@ -134,14 +135,31 @@ class TcIntegrationLayout extends Component { event.preventDefault(); // if this is a freecodecamp lesson, change its domain and path - if (url.host === 'learn.freecodecamp.org') { - url.host = new URL(document.referrer).host; + const fccHost = 'freecodecamp.org'; + if (url.host.endsWith(fccHost)) { // TODO: it would be nice to not require that the FCC // app knows about the paths in the platform UI, but // creating a way to share this info would be complex and // time consuming, so we can handle it when we get another // provider. - url.pathname = `learn/freecodecamp${url.pathname}`; + + // set the pathname for the 2 flavors of lesson URL + const platformPathPrefix = 'learn/freecodecamp'; + const learnPrefix = '/learn/'; + if (url.host === `learn.${fccHost}`) { + url.pathname = `${platformPathPrefix}${url.pathname}`; + } else if ( + url.host === `www.${fccHost}` && + url.pathname.startsWith(learnPrefix) + ) { + url.pathname = url.pathname.replace( + learnPrefix, + `/${platformPathPrefix}/` + ); + } + + // set the host to the iframe's parent domain + url.host = new URL(document.referrer).host; } // now open the url in a new tab From 27c59a82b731bf7656adb694bd28433b729a20f4 Mon Sep 17 00:00:00 2001 From: Brooke Date: Thu, 25 Aug 2022 10:35:09 -0700 Subject: [PATCH 14/23] TCA-326 #comment This commit suppresses the confirmation modal entirely #time 1h --- .../src/templates/Challenges/redux/execute-challenge-saga.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index 6eb99c87a32b24..0798391b84f34f 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -45,7 +45,7 @@ import { updateLogs, logsToConsole, updateTests, - openModal, + // openModal, isBuildEnabledSelector, disableBuildOnError, updateTestsRunning @@ -137,7 +137,8 @@ export function* executeChallengeSaga({ payload }) { playTone('tests-failed'); } if (challengeComplete && payload?.showCompletionModal) { - yield put(openModal('completion')); + // TOPCODER: do not open modal + // yield put(openModal('completion')); } yield put(updateConsole(i18next.t('learn.tests-completed'))); yield put(logsToConsole(i18next.t('learn.console-output'))); From 8b406709d31791ae17d035b8085f43d1e7cc0781 Mon Sep 17 00:00:00 2001 From: Brooke Date: Thu, 25 Aug 2022 12:48:54 -0700 Subject: [PATCH 15/23] TCA-314 #comment This PR adds Segment analytics with env-specific behavior. It also removes the FCC GA tracking. #time 6h --- client/src/analytics/segment/Segment.tsx | 29 ++++ client/src/analytics/segment/index.ts | 1 + .../src/analytics/segment/segment-snippet.js | 65 ++++++++ .../Challenges/classic/desktop-layout.tsx | 149 +++++++++--------- .../Challenges/classic/tc-layout.tsx | 104 ++++++------ config/analytics-settings.js | 8 +- 6 files changed, 233 insertions(+), 123 deletions(-) create mode 100644 client/src/analytics/segment/Segment.tsx create mode 100644 client/src/analytics/segment/index.ts create mode 100644 client/src/analytics/segment/segment-snippet.js diff --git a/client/src/analytics/segment/Segment.tsx b/client/src/analytics/segment/Segment.tsx new file mode 100644 index 00000000000000..28f76e0a57635f --- /dev/null +++ b/client/src/analytics/segment/Segment.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; + +import { + devSegmentId, + prodSegmentId +} from '../../../../config/analytics-settings'; +import envData from '../../../../config/env.json'; +import segment from './segment-snippet'; + +interface SegmentModel { + load: (id: string) => void; + page: () => void; +} + +const segmentModel: SegmentModel = segment as unknown as SegmentModel; + +const Segment: FC = () => { + // if we have a key for this environment, load it + const segmentId = + envData.deploymentEnv === 'staging' ? devSegmentId : prodSegmentId; + if (segmentId) { + segmentModel.load(segmentId); + segmentModel.page(); + } + + return null; +}; + +export default Segment; diff --git a/client/src/analytics/segment/index.ts b/client/src/analytics/segment/index.ts new file mode 100644 index 00000000000000..67a18436e91638 --- /dev/null +++ b/client/src/analytics/segment/index.ts @@ -0,0 +1 @@ +export { default as Segment } from './Segment'; diff --git a/client/src/analytics/segment/segment-snippet.js b/client/src/analytics/segment/segment-snippet.js new file mode 100644 index 00000000000000..831e9ef409094c --- /dev/null +++ b/client/src/analytics/segment/segment-snippet.js @@ -0,0 +1,65 @@ +function SegmentSnippet() { + var analytics = []; + + if (analytics.initialize) { + return; + } + if (analytics.invoked) { + window.console && + console.error && + console.error('Segment snippet included twice.'); + return; + } + analytics.invoked = !0; + analytics.methods = [ + 'trackSubmit', + 'trackClick', + 'trackLink', + 'trackForm', + 'pageview', + 'identify', + 'reset', + 'group', + 'track', + 'ready', + 'alias', + 'debug', + 'page', + 'once', + 'off', + 'on', + 'addSourceMiddleware', + 'addIntegrationMiddleware', + 'setAnonymousId', + 'addDestinationMiddleware' + ]; + analytics.factory = function (t) { + return function () { + var e = Array.prototype.slice.call(arguments); + e.unshift(t); + analytics.push(e); + return analytics; + }; + }; + for (var t = 0; t < analytics.methods.length; t++) { + var e = analytics.methods[t]; + analytics[e] = analytics.factory(e); + } + analytics.load = function (t, e) { + var n = document.createElement('script'); + n.type = 'text/javascript'; + n.async = !0; + n.src = + 'https://cdn.segment.com/analytics.js/v1/' + t + '/analytics.min.js'; + var a = document.getElementsByTagName('script')[0]; + a.parentNode.insertBefore(n, a); + analytics._loadOptions = e; + }; + analytics.SNIPPET_VERSION = '4.1.0'; + // analytics.load("SEGMENT_ANALYTICS_KEY"); - don't load here and let the component decide to load or not + // analytics.page(); - don't call the page, each app should call it when it loads a page by itself + + return { ...analytics }; +} + +export default SegmentSnippet(); diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index 4bf573240096c7..fe5c2cc8d92cbc 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -3,6 +3,7 @@ import React, { useState, ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; import { challengeTypes } from '../../../../utils/challenge-types'; +import { Segment } from '../../../analytics/segment'; import { ChallengeFile, ChallengeFiles, @@ -110,86 +111,90 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { } = layoutState; return ( -
- -
- - {!projectBasedChallenge && showInstructions && ( - - {instructions} - - )} - {!projectBasedChallenge && displayEditor && ( - - )} + <> +
+ +
+ + {!projectBasedChallenge && showInstructions && ( + + {instructions} + + )} + {!projectBasedChallenge && displayEditor && ( + + )} - {challengeFile && displayEditor && ( - - - + - {editor} - - {displayNotes && ( - - )} - {displayNotes && ( - - {notes} - - )} - - - )} - - {(displayPreview || displayConsole) && ( - - )} - - {(displayPreview || displayConsole) && ( - - - {displayPreview && ( - - {preview} - - )} - {displayConsole && displayPreview && ( - - )} - {displayConsole && ( - {testOutput} + {editor} - )} - - - )} - + {displayNotes && ( + + )} + {displayNotes && ( + + {notes} + + )} + + + )} + + {(displayPreview || displayConsole) && ( + + )} + + {(displayPreview || displayConsole) && ( + + + {displayPreview && ( + + {preview} + + )} + {displayConsole && displayPreview && ( + + )} + {displayConsole && ( + + {testOutput} + + )} + + + )} + +
-
+ + + ); }; diff --git a/client/src/templates/Challenges/classic/tc-layout.tsx b/client/src/templates/Challenges/classic/tc-layout.tsx index 66776ef5a163c4..fd9ae1d5c8bd8e 100755 --- a/client/src/templates/Challenges/classic/tc-layout.tsx +++ b/client/src/templates/Challenges/classic/tc-layout.tsx @@ -1,7 +1,9 @@ import { first } from 'lodash-es'; import React, { ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; + import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; +import Segment from '../../../analytics/segment/Segment'; import { ChallengeFile, ChallengeFiles, @@ -71,62 +73,66 @@ const TcLayout = (props: TcLayoutProps): JSX.Element => { } = layoutState; return ( -
- - - {instructions} - - - - - - - {challengeFile && ( - - +
+ + + {instructions} + + + + + + + {challengeFile && ( + - {editor} + + {editor} + + + )} + + + {displayNotes && ( + + )} + {displayNotes && ( + + {notes} + + )} + + {hasPreview && ( + <> + + + {preview} - + )} - - {displayNotes && ( - )} - {displayNotes && ( - - {notes} + + {testOutput} - )} + + + +
- {hasPreview && ( - <> - - - {preview} - - - )} - - - - {testOutput} - -
-
-
-
+ + ); }; diff --git a/config/analytics-settings.js b/config/analytics-settings.js index 106725cc3b67eb..1a0d36f8f7a854 100644 --- a/config/analytics-settings.js +++ b/config/analytics-settings.js @@ -1,2 +1,6 @@ -exports.prodAnalyticsId = 'UA-55446531-10'; -exports.devAnalyticsId = 'UA-55446531-19'; +exports.prodAnalyticsId = null; +exports.prodSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; + +exports.devAnalyticsId = null; +// TODO: TCA-371 set this to null so we're not tracking non-prod +exports.devSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; From 624a624b195a96e69243ccb7eba7dfe01286dd23 Mon Sep 17 00:00:00 2001 From: Brooke Date: Thu, 25 Aug 2022 13:03:56 -0700 Subject: [PATCH 16/23] TCA-314 #comment This commit adds GTM to FCC w/env-specific accounts #time 15m --- client/package.json | 1 + .../google-tag-manater/GoogleTagManager.tsx | 29 +++++++++++++++++++ .../src/analytics/google-tag-manater/index.ts | 1 + client/src/analytics/segment/Segment.tsx | 3 +- .../Challenges/classic/desktop-layout.tsx | 2 ++ .../Challenges/classic/tc-layout.tsx | 2 ++ config/analytics-settings.js | 7 +++-- package-lock.json | 12 ++++++++ 8 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 client/src/analytics/google-tag-manater/GoogleTagManager.tsx create mode 100644 client/src/analytics/google-tag-manater/index.ts diff --git a/client/package.json b/client/package.json index 8b6dff4e94ea00..086adaa7c84aa5 100644 --- a/client/package.json +++ b/client/package.json @@ -96,6 +96,7 @@ "react-dom": "16.14.0", "react-final-form": "6.5.9", "react-ga": "3.3.0", + "react-gtm-module": "^2.0.11", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", "react-i18next": "11.16.9", diff --git a/client/src/analytics/google-tag-manater/GoogleTagManager.tsx b/client/src/analytics/google-tag-manater/GoogleTagManager.tsx new file mode 100644 index 00000000000000..0027871fd47d72 --- /dev/null +++ b/client/src/analytics/google-tag-manater/GoogleTagManager.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import TagManager from 'react-gtm-module'; + +import { + devTagManagerId, + prodTagManagerId +} from '../../../../config/analytics-settings'; + +import envData from '../../../../config/env.json'; + +/* eslint-disable @typescript-eslint/ban-types */ +const GoogleTagManager: FC<{}> = () => { + // if we have an ID + // then tags are supported in this environment, + // so initialize them + const segmentId = + envData.deploymentEnv === 'staging' ? devTagManagerId : prodTagManagerId; + if (segmentId) { + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + /* eslint-disable @typescript-eslint/no-unsafe-call */ + TagManager.initialize({ + gtmId: segmentId + }); + } + + return null; +}; + +export default GoogleTagManager; diff --git a/client/src/analytics/google-tag-manater/index.ts b/client/src/analytics/google-tag-manater/index.ts new file mode 100644 index 00000000000000..aa72e99ec3d0b2 --- /dev/null +++ b/client/src/analytics/google-tag-manater/index.ts @@ -0,0 +1 @@ +export { default as GoogleTagManager } from './GoogleTagManager'; diff --git a/client/src/analytics/segment/Segment.tsx b/client/src/analytics/segment/Segment.tsx index 28f76e0a57635f..f77313ddb9e436 100644 --- a/client/src/analytics/segment/Segment.tsx +++ b/client/src/analytics/segment/Segment.tsx @@ -14,7 +14,8 @@ interface SegmentModel { const segmentModel: SegmentModel = segment as unknown as SegmentModel; -const Segment: FC = () => { +/* eslint-disable @typescript-eslint/ban-types */ +const Segment: FC<{}> = () => { // if we have a key for this environment, load it const segmentId = envData.deploymentEnv === 'staging' ? devSegmentId : prodSegmentId; diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index fe5c2cc8d92cbc..0eefa0a084d1a4 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -3,6 +3,7 @@ import React, { useState, ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; import { challengeTypes } from '../../../../utils/challenge-types'; +import { GoogleTagManager } from '../../../analytics/google-tag-manater'; import { Segment } from '../../../analytics/segment'; import { ChallengeFile, @@ -194,6 +195,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
+ ); }; diff --git a/client/src/templates/Challenges/classic/tc-layout.tsx b/client/src/templates/Challenges/classic/tc-layout.tsx index fd9ae1d5c8bd8e..edf9a605054df0 100755 --- a/client/src/templates/Challenges/classic/tc-layout.tsx +++ b/client/src/templates/Challenges/classic/tc-layout.tsx @@ -3,6 +3,7 @@ import React, { ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; +import { GoogleTagManager } from '../../../analytics/google-tag-manater'; import Segment from '../../../analytics/segment/Segment'; import { ChallengeFile, @@ -132,6 +133,7 @@ const TcLayout = (props: TcLayoutProps): JSX.Element => {
+ ); }; diff --git a/config/analytics-settings.js b/config/analytics-settings.js index 1a0d36f8f7a854..976e96c16d6ae5 100644 --- a/config/analytics-settings.js +++ b/config/analytics-settings.js @@ -1,6 +1,9 @@ exports.prodAnalyticsId = null; -exports.prodSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; - exports.devAnalyticsId = null; + +exports.prodSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; // TODO: TCA-371 set this to null so we're not tracking non-prod exports.devSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; + +exports.prodTagManagerId = 'GTM-MXXQHG8'; +exports.devTagManagerId = 'GTM-W7B537Z'; diff --git a/package-lock.json b/package-lock.json index 6f01f465cef24d..7c10e249b2838f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -977,6 +977,7 @@ "react-dom": "16.14.0", "react-final-form": "6.5.9", "react-ga": "3.3.0", + "react-gtm-module": "^2.0.11", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", "react-i18next": "11.16.9", @@ -43540,6 +43541,11 @@ "react": "^15.6.2 || ^16.0 || ^17" } }, + "node_modules/react-gtm-module": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", + "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + }, "node_modules/react-helmet": { "version": "6.1.0", "license": "MIT", @@ -57834,6 +57840,7 @@ "react-dom": "16.14.0", "react-final-form": "6.5.9", "react-ga": "3.3.0", + "react-gtm-module": "^2.0.11", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", "react-i18next": "11.16.9", @@ -84527,6 +84534,11 @@ "version": "3.3.0", "requires": {} }, + "react-gtm-module": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", + "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + }, "react-helmet": { "version": "6.1.0", "requires": { From de346d6499ea48748fb27702baeeee89175c7236 Mon Sep 17 00:00:00 2001 From: Brooke Date: Thu, 25 Aug 2022 14:00:52 -0700 Subject: [PATCH 17/23] TCA-314 #comment This commit removes heap from non-prod environments #time5m --- config/analytics-settings.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/analytics-settings.js b/config/analytics-settings.js index 976e96c16d6ae5..b2b6bad8f3abbf 100644 --- a/config/analytics-settings.js +++ b/config/analytics-settings.js @@ -2,8 +2,7 @@ exports.prodAnalyticsId = null; exports.devAnalyticsId = null; exports.prodSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; -// TODO: TCA-371 set this to null so we're not tracking non-prod -exports.devSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; +exports.devSegmentId = null; exports.prodTagManagerId = 'GTM-MXXQHG8'; exports.devTagManagerId = 'GTM-W7B537Z'; From e5798c6c4d0ac08e834578c576043f7d1b32e73d Mon Sep 17 00:00:00 2001 From: Brooke Date: Fri, 26 Aug 2022 11:03:29 -0700 Subject: [PATCH 18/23] TCA-373 #comment This PR updates external link handling so that it supports FCC pages that aren't lesssons #time 30m --- client/src/components/layouts/tc-integration.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index d7211792668391..136cd7f7acd1a3 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -146,8 +146,10 @@ class TcIntegrationLayout extends Component { // set the pathname for the 2 flavors of lesson URL const platformPathPrefix = 'learn/freecodecamp'; const learnPrefix = '/learn/'; + let updateHost = false; if (url.host === `learn.${fccHost}`) { url.pathname = `${platformPathPrefix}${url.pathname}`; + updateHost = true; } else if ( url.host === `www.${fccHost}` && url.pathname.startsWith(learnPrefix) @@ -156,10 +158,13 @@ class TcIntegrationLayout extends Component { learnPrefix, `/${platformPathPrefix}/` ); + updateHost = true; } // set the host to the iframe's parent domain - url.host = new URL(document.referrer).host; + if (updateHost) { + url.host = new URL(document.referrer).host; + } } // now open the url in a new tab From a125218970a1cbd20927dbbdb16da631b7cfbf13 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Aug 2022 15:47:07 +0300 Subject: [PATCH 19/23] TCA-352 - fix text formatting for code blocks --- client/src/components/layouts/prism-night.css | 8 ++++++- .../Challenges/components/prism-formatted.tsx | 24 +++++++++++++++++-- .../src/templates/Challenges/video/Show.tsx | 6 ++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/client/src/components/layouts/prism-night.css b/client/src/components/layouts/prism-night.css index 04197d19ac2a74..5ddb302d8b613a 100644 --- a/client/src/components/layouts/prism-night.css +++ b/client/src/components/layouts/prism-night.css @@ -6,7 +6,8 @@ */ .dark-palette code[class*='language-'], -.dark-palette pre[class*='language-'] { +.dark-palette code[class*='language-'], +pre[class*='language-'].dark-palette { color: var(--secondary-color); font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; font-size: 1em; @@ -28,11 +29,13 @@ text-shadow: none; } +pre[class*='language-'].dark-palette code[class*='language-'], .dark-palette pre[class*='language-'] code[class*='language-'] { color: var(--quaternary-color); } /* Code blocks */ +pre[class*='language-'].dark-palette, .dark-palette pre[class*='language-'] { padding: 1em; margin: 0.5em 0; @@ -47,12 +50,15 @@ padding in night mode */ } .dark-palette :not(pre) > code[class*='language-'], +pre[class*='language-'].dark-palette, .dark-palette pre[class*='language-'] { background: var(--primary-background); } .dark-palette pre[class*='language-']::selection, +pre[class*='language-'].dark-palette::selection, .dark-palette pre[class*='language-'] ::selection, +pre[class*='language-'].dark-palette ::selection, .dark-palette code[class*='language-']::selection, .dark-palette code[class*='language-'] ::selection { background: var(--selection-color); diff --git a/client/src/templates/Challenges/components/prism-formatted.tsx b/client/src/templates/Challenges/components/prism-formatted.tsx index b71e92603eb234..142ed068057340 100644 --- a/client/src/templates/Challenges/components/prism-formatted.tsx +++ b/client/src/templates/Challenges/components/prism-formatted.tsx @@ -4,17 +4,37 @@ import React, { useRef, useEffect } from 'react'; interface PrismFormattedProps { className?: string; text: string; + lineNumbers?: boolean; + darkTheme?: boolean; } -function PrismFormatted({ className, text }: PrismFormattedProps): JSX.Element { +/** + * Add css formatting classes to the
 elements based on showLineNumbers and darkTheme params
+ * @param container
+ * @param showLineNumbers
+ * @param darkTheme
+ */
+const addFormattingClassesForPres = (container: HTMLElement, showLineNumbers = true, darkTheme = true) => {
+  const codeBlocks: HTMLElement[] = [].slice.call(container.querySelectorAll('[class*="language-"]'));
+  // we want to formatt the 
 element, not the , get parent if current element is not PRE
+  const preElements: HTMLPreElement[] = codeBlocks.map(c => (c.nodeName === 'PRE' ? c : c.parentElement) as HTMLPreElement);
+
+  for(const pre of preElements) {
+    pre.classList.toggle('line-numbers', showLineNumbers);
+    pre.classList.toggle('dark-palette', darkTheme);
+  }
+}
+
+function PrismFormatted({ className, text, ...props }: PrismFormattedProps): JSX.Element {
   const instructionsRef = useRef(null);
 
   useEffect(() => {
     // Just in case 'current' has not been created, though it should have been.
     if (instructionsRef.current) {
+      addFormattingClassesForPres(instructionsRef.current, props.lineNumbers, props.darkTheme);
       Prism.highlightAllUnder(instructionsRef.current);
     }
-  }, []);
+  }, [props.darkTheme, props.lineNumbers]);
 
   return (
     
{ const blockNameTitle = `${t( `intro:${superBlock}.blocks.${block}.title` )} - ${title}`; + return ( this.handleSubmit(solution)} @@ -245,10 +246,7 @@ class ShowVideo extends Component { className='video-description' > - +
From 1f02742cdd64fbedb9f0f6bde61fd52269fb29c5 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Aug 2022 15:54:21 +0300 Subject: [PATCH 20/23] lint --- .../Challenges/components/prism-formatted.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/client/src/templates/Challenges/components/prism-formatted.tsx b/client/src/templates/Challenges/components/prism-formatted.tsx index 142ed068057340..dfb54222afe8fb 100644 --- a/client/src/templates/Challenges/components/prism-formatted.tsx +++ b/client/src/templates/Challenges/components/prism-formatted.tsx @@ -14,24 +14,40 @@ interface PrismFormattedProps { * @param showLineNumbers * @param darkTheme */ -const addFormattingClassesForPres = (container: HTMLElement, showLineNumbers = true, darkTheme = true) => { - const codeBlocks: HTMLElement[] = [].slice.call(container.querySelectorAll('[class*="language-"]')); +const addFormattingClassesForPres = ( + container: HTMLElement, + showLineNumbers = true, + darkTheme = true +) => { + const codeBlocks: HTMLElement[] = [].slice.call( + container.querySelectorAll('[class*="language-"]') + ); // we want to formatt the
 element, not the , get parent if current element is not PRE
-  const preElements: HTMLPreElement[] = codeBlocks.map(c => (c.nodeName === 'PRE' ? c : c.parentElement) as HTMLPreElement);
+  const preElements: HTMLPreElement[] = codeBlocks.map(
+    c => (c.nodeName === 'PRE' ? c : c.parentElement) as HTMLPreElement
+  );
 
-  for(const pre of preElements) {
+  for (const pre of preElements) {
     pre.classList.toggle('line-numbers', showLineNumbers);
     pre.classList.toggle('dark-palette', darkTheme);
   }
-}
+};
 
-function PrismFormatted({ className, text, ...props }: PrismFormattedProps): JSX.Element {
+function PrismFormatted({
+  className,
+  text,
+  ...props
+}: PrismFormattedProps): JSX.Element {
   const instructionsRef = useRef(null);
 
   useEffect(() => {
     // Just in case 'current' has not been created, though it should have been.
     if (instructionsRef.current) {
-      addFormattingClassesForPres(instructionsRef.current, props.lineNumbers, props.darkTheme);
+      addFormattingClassesForPres(
+        instructionsRef.current,
+        props.lineNumbers,
+        props.darkTheme
+      );
       Prism.highlightAllUnder(instructionsRef.current);
     }
   }, [props.darkTheme, props.lineNumbers]);

From 6e4c4d52a2209a9615870600c4c3f328fad9b8a6 Mon Sep 17 00:00:00 2001
From: Brooke 
Date: Tue, 30 Aug 2022 12:31:46 -0700
Subject: [PATCH 21/23] Trigger deployment

---
 Jenkinsfile | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/Jenkinsfile b/Jenkinsfile
index 0dc35d669c8bf2..663064f06fe2fe 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -12,7 +12,7 @@ if (!branchfilter.contains(env.BRANCH_NAME)) {
     return
 }
 
-// Define branch specific vars
+// Define dev-specific vars
 if (env.BRANCH_NAME == 'dev') {
     DEPLOY_ENV = 'DEV'
     LOGICAL_ENV = 'dev'
@@ -23,6 +23,7 @@ if (env.BRANCH_NAME == 'dev') {
     ENABLE_CACHE = false
 }
 
+// Define prod-specific vars
 if (env.BRANCH_NAME == 'prod') {
     DEPLOY_ENV = 'PROD'
     LOGICAL_ENV = 'prod'

From 9e97bf31c90869b05ceefe3a8be3abfc6938234b Mon Sep 17 00:00:00 2001
From: Brooke 
Date: Wed, 31 Aug 2022 16:43:34 -0700
Subject: [PATCH 22/23] TCA-43 #comment This commit reverts all the changes
 from this ticket #time 15m

---
 .../build-a-25-5-clock.md                     | 1151 +----------------
 1 file changed, 3 insertions(+), 1148 deletions(-)

diff --git a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md
index 29128537355e2e..ca80567a6198ca 100644
--- a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md
+++ b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md
@@ -1,7 +1,7 @@
 ---
 id: bd7158d8c442eddfaeb5bd0f
 title: Build a 25 + 5 Clock
-challengeType: 14
+challengeType: 3
 forumTopicId: 301373
 dashedName: build-a-25--5-clock
 ---
@@ -70,1155 +70,10 @@ You can use any mix of HTML, JavaScript, CSS, Bootstrap, SASS, React, Redux, and
 
 **User Story #28:** The audio element with id of `beep` must stop playing and be rewound to the beginning when the element with the id of `reset` is clicked.
 
-# --hints--
+You can build your project by using this CodePen template and clicking `Save` to create your own pen. Or you can use this CDN link to run the tests in any environment you like: `https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js`
 
-You should have an element with `id="break-label"` that contains a string (e.g. "Break Length").
+Once you're done, submit the URL to your working project with all its tests passing.
 
-```js
-const breakTitle = document.getElementById('break-label');
-assert.isAbove(
-  breakTitle.innerText.length,
-  0,
-  'Element does not contain a string'
-);
-```
-
-You should have an element with `id="session-label"` that contains a string (e.g. "Session Length").
-
-```js
-const sessionTitle = document.getElementById('session-label');
-assert.isAbove(
-  sessionTitle.innerText.length,
-  0,
-  'Element does not contain a string'
-);
-```
-
-You should have two clickable elements with corresponding IDs: `id="break-decrement"` and `id="session-decrement"`.
-
-```js
-assert.isNotNull(document.getElementById('break-decrement'));
-assert.isNotNull(document.getElementById('session-decrement'));
-```
-
-You should have two clickable elements with corresponding IDs: `id="break-increment"` and `id="session-increment"`.
-
-```js
-assert.isNotNull(document.getElementById('break-increment'));
-assert.isNotNull(document.getElementById('session-increment'));
-```
-
-You should have an element with a corresponding `id="break-length"`, which by default (on load) displays a value of 5.
-
-```js
-const breakLength = document.getElementById('break-length');
-assert.strictEqual(
-  breakLength.nodeName.toLowerCase() === 'input'
-    ? breakLength.value
-    : breakLength.innerText,
-  '5',
-  'A value of 5 is not displayed by default'
-);
-```
-
-You should have an element with a corresponding `id="session-length"`, which by default displays a value of 25.
-
-```js
-const sessionLength = document.getElementById('session-length');
-assert.strictEqual(
-  sessionLength.nodeName.toLowerCase() === 'input'
-    ? sessionLength.value
-    : sessionLength.innerText,
-  '25',
-  'A value of 25 is not displayed by default'
-);
-```
-
-You should have an element with a corresponding `id="timer-label"`, that contains a string indicating a session is initialized (e.g. "Session").
-
-```js
-const timerLabel = document.getElementById('timer-label');
-assert.isAbove(
-  timerLabel.innerText.length,
-  0,
-  'Element does not contain a string'
-);
-```
-
-You should have an element with corresponding `id="time-left"`. NOTE: Paused or running, the value in this field should always be displayed in `mm:ss` format (i.e. 25:00).
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const target = document.getElementById('time-left');
-  assert.isNotNull(target);
-  assert.strictEqual(
-    timerRe.exec(target.innerText)[1],
-    '25',
-    'time-left is not formatted correctly'
-  );
-  // Set session length to 60
-  Array(35).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // wait for 1.5 seconds to allow any re-renders to catch up
-  await timeout(1500);
-  assert.strictEqual(
-    timerRe.exec(target.innerText)[1],
-    '60',
-    'time-left is not formatted correctly'
-  );
-})();
-```
-
-You should have a clickable element with a corresponding `id="start_stop"`.
-
-```js
-assert.isNotNull(document.getElementById('start_stop'));
-```
-
-You should have a clickable element with a corresponding `id="reset"`.
-
-```js
-assert.isNotNull(document.getElementById('reset'));
-```
-
-When I click the element with the id of `reset`, any running timer should be stopped, the value within `id="break-length"` should return to `5`, the value within `id="session-length"` should return to 25, and the element with `id="time-left"` should reset to its default state.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // decrement session and break length
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  window.setTimeout = savedSetTimeout;
-  window.setInterval = savedSetInterval;
-
-  // once timer has reached zero wait 1.5 seconds then reset and
-  // see if every default value is reset
-  await timeout(1500);
-
-  Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  const timerLabelAfterReset =
-    document.getElementById('timer-label').innerText;
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const secondsAfterReset = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  // see if timer label changed back
-  assert.strictEqual(
-    timerLabelAfterReset,
-    document.getElementById('timer-label') &&
-    document.getElementById('timer-label').innerText,
-    'Default timer label was not properly reset '
-  );
-
-  // wait another 1.5 seconds to be sure value has not changed
-  // (25 + 5 clock is stopped)
-  await timeout(1500);
-
-  const breakLengthElement = document.getElementById('break-length');
-  assert.strictEqual(
-    breakLengthElement.nodeName.toLowerCase() === 'input'
-      ? breakLengthElement.value
-      : breakLengthElement.innerText,
-    '5',
-    'Default values for break length were not properly reset'
-  );
-
-  const sessionLengthElement = document.getElementById('session-length');
-  assert.strictEqual(
-    sessionLengthElement.nodeName.toLowerCase() === 'input'
-      ? sessionLengthElement.value
-      : sessionLengthElement.innerText,
-    '25',
-    'Default values for session length were not properly reset'
-  );
-
-  const secondsAfterAWhile = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.strictEqual(
-    secondsAfterAWhile,
-    secondsAfterReset,
-    '25 + 5 has paused but time continued elapsing'
-  );
-})();
-```
-
-When I click the element with the id of `break-decrement`, the value within `id="break-length"` decrements by a value of 1, and I can see the updated value.
-
-```js
-Array(4).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const breakLengthElement = document.getElementById('break-length');
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '1'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array.of('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '4'
-);
-```
-
-When I click the element with the id of `break-increment`, the value within `id="break-length"` increments by a value of 1, and I can see the updated value.
-
-```js
-Array(4).fill('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const breakLengthElement = document.getElementById('break-length');
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '8'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array.of('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '6'
-);
-```
-
-When I click the element with the id of `session-decrement`, the value within `id="session-length"` decrements by a value of 1, and I can see the updated value.
-
-```js
-Array(4).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const sessionLengthElement = document.getElementById('session-length');
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '21'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array.of('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '24'
-);
-```
-
-When I click the element with the id of `session-increment`, the value within `id="session-length"` increments by a value of 1, and I can see the updated value.
-
-```js
-Array(4).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const sessionLengthElement = document.getElementById('session-length');
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '28'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array.of('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '26'
-);
-```
-
-I should not be able to set a session or break length to <= 0.
-
-```js
-Array(10).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const breakLengthElement = document.getElementById('break-length');
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '1',
-  'Value in element with id of "break-length" is less than 1.'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array(30).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const sessionLengthElement = document.getElementById('session-length');
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '1',
-  'Value in element with id of "session-length" is less than 1.'
-);
-```
-
-I should not be able to set a session or break length to > 60.
-
-```js
-Array(60).fill('break-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const breakLengthElement = document.getElementById('break-length');
-assert.strictEqual(
-  breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText,
-  '60',
-  'Value in element with id of "break-length" is greater than 60.'
-);
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-Array(40).fill('session-increment').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const sessionLengthElement = document.getElementById('session-length');
-assert.strictEqual(
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText,
-  '60',
-  'Value in element with id of "session-length" is greater than 60.'
-);
-```
-
-When I first click the element with `id="start_stop"`, the timer should begin running from the value currently displayed in `id="session-length"`, even if the value has been incremented or decremented from the original value of 25.
-
-```js
-Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-const sessionLengthElement = document.getElementById('session-length');
-assert.strictEqual(
-  timerRe.exec(document.getElementById('time-left').innerText)[1],
-  sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText
-);
-
-// stop the 25 + 5 clock
-Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-```
-
-If the timer is running, the element with the id of `time-left` should display the remaining time in `mm:ss` format (decrementing by a value of 1 and updating the display every 1000ms).
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  await timeout(2500);
-
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const secondsBefore = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  // wait 1.5 seconds then see if displayed time has changed
-  // (decremented)
-  await timeout(1500);
-
-  const secondsAfter = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.isAbove(
-    +secondsBefore,
-    +secondsAfter,
-    '25 + 5 clock has started but time displayed is not changing '
-  );
-
-  // stop the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-})();
-```
-
-If the timer is running and I click the element with `id="start_stop"`, the countdown should pause.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const secondsBefore = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  // wait 1.5 seconds then see if displayed time has changed
-  await timeout(1500);
-
-  const secondsAfter = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.notStrictEqual(
-    secondsAfter,
-    secondsBefore,
-    '25 + 5 has started but time displayed is not changing'
-  );
-
-  // Pause the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait another 1.5 seconds to be sure value has not changed
-  await timeout(1500);
-
-  const secondsAfterPause = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.strictEqual(
-    secondsAfterPause,
-    secondsAfter,
-    '25 + 5 clock has paused but time continued elapsing'
-  );
-})();
-```
-
-If the timer is paused and I click the element with `id="start_stop"`, the countdown should resume running from the point at which it was paused.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const secondsBefore = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  // wait 1.5 seconds then see if displayed time has changed
-  await timeout(1500);
-
-  const secondsAfter = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.notStrictEqual(
-    secondsAfter,
-    secondsBefore,
-    '25 + 5 clock has started but time displayed is not changing'
-  );
-
-  // Pause the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait another 1.5 seconds to be sure value has not changed
-  await timeout(1500);
-
-  const secondsAfterPause = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.strictEqual(
-    secondsAfterPause,
-    secondsAfter,
-    '25 + 5 clock has paused but time continued elapsing'
-  );
-
-  // Resume the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait another 1.5 seconds to be sure time is decrementing again
-  await timeout(1500);
-
-  const secondsAfterResume = timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[2];
-
-  assert.isBelow(
-    +secondsAfterResume,
-    +secondsAfterPause,
-    '25 + 5 clock has resumed but displayed time is not changing '
-  );
-
-  // stop the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-})();
-```
-
-When a session countdown reaches zero (NOTE: timer MUST reach 00:00), and a new countdown begins, the element with the id of `timer-label` should display a string indicating a break has begun.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-  const timerLabelElement = document.getElementById('timer-label');
-
-  Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // we decrement session time to the minimum (1 minute)
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  let tLabelA = timerLabelElement.innerHTML;
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  await timeout(1500);
-
-  const breakLengthElement = document.getElementById('break-length');
-  const breakLength = +(breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText);
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const breakTime = +timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[1];
-  assert.isAtMost(
-    breakTime,
-    breakLength,
-    "Break time didn't start with the correct value."
-  );
-
-  let tLabelB = timerLabelElement.innerHTML;
-
-  assert.notStrictEqual(
-    tLabelB,
-    tLabelA,
-    "Timer has reached zero but didn't switch to Break time"
-  );
-})();
-```
-
-When a session countdown reaches zero (NOTE: timer MUST reach 00:00), a new break countdown should begin, counting down from the value currently displayed in the `id="break-length"` element.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-  const timerLabelElement = document.getElementById('timer-label');
-
-  Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // we decrement session time to the minimum (1 minute)
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  let tLabelA = timerLabelElement.innerHTML;
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  // wait while timer label switches
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      observer.disconnect();
-      resolve();
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timerLabelElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  const tLabelB = timerLabelElement.innerHTML;
-
-  assert.notStrictEqual(
-    tLabelB,
-    tLabelA,
-    "Timer has reached zero but didn't switch to Break time"
-  );
-
-  const breakLengthElement = document.getElementById('break-length');
-  const breakLength = +(breakLengthElement.nodeName.toLowerCase() === 'input'
-    ? breakLengthElement.value
-    : breakLengthElement.innerText);
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const breakTime = +timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[1];
-  assert.strictEqual(
-    breakTime,
-    breakLength,
-    "Timer has switched to Break time, but it didn't start with " +
-      'the correct value.'
-  );
-
-  // stop the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-})();
-```
-
-When a break countdown reaches zero (NOTE: timer MUST reach 00:00), and a new countdown begins, the element with the id of `timer-label` should display a string indicating a session has begun.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-  const timerLabelElement = document.getElementById('timer-label');
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // decrement session length and break length to the minimum (1 minute)
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  const breakLabel = timerLabelElement.innerHTML;
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  await timeout(1500);
-
-  const sessionLabel = timerLabelElement.innerHTML;
-
-  assert.notStrictEqual(
-    sessionLabel,
-    breakLabel,
-    "Timer has reached zero but didn't switch back to Session time."
-  );
-
-  // stop the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-})();
-```
-
-When a break countdown reaches zero (NOTE: timer MUST reach 00:00), a new session countdown should begin, counting down from the value currently displayed in the `id="session-length"` element.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-  const timerLabelElement = document.getElementById('timer-label');
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // decrement session length and break length to the minimum (1 minute)
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  Array(60).fill('break-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  // wait while timer label switches
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      observer.disconnect();
-      resolve();
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timerLabelElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  let tLabelA = timerLabelElement.innerHTML;
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  // wait while timer label switches
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      observer.disconnect();
-      resolve();
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timerLabelElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  const tLabelB = timerLabelElement.innerHTML;
-
-  assert.notStrictEqual(
-    tLabelB,
-    tLabelA,
-    "Timer has reached zero but didn't switch to Session time"
-  );
-
-  const sessionLengthElement = document.getElementById('session-length');
-  const sessionLength = +(sessionLengthElement.nodeName.toLowerCase() === 'input'
-    ? sessionLengthElement.value
-    : sessionLengthElement.innerText);
-  const timerRe = new RegExp(/^(\d{2,4})[\.:,\/](\d{2})$/);
-  const currentTime = +timerRe.exec(
-    document.getElementById('time-left').innerText
-  )[1];
-  assert.strictEqual(
-    currentTime,
-    sessionLength,
-    'Timer has switched back to Session time, but it ' +
-      "didn't start with the correct value."
-  );
-
-  // stop the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-})();
-```
-
-When a countdown reaches zero (NOTE: timer MUST reach 00:00), a sound indicating that time is up should play. This should utilize an HTML5 `audio` tag and have a corresponding `id="beep"`.
-
-```js
-(async () => {
-  const timeout = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-
-  const timeLeftElement = document.getElementById('time-left');
-
-  assert.isNotNull(
-    document.querySelector('audio#beep'),
-    'There is no audio tag with ID "beep" on the page.'
-  );
-
-  const savedSetTimeout = window.setTimeout;
-  const savedSetInterval = window.setInterval;
-  window.setTimeout = (fun) => {
-    return savedSetTimeout(fun, 30);
-  };
-  window.setInterval = (fun) => {
-    return savedSetInterval(fun, 30);
-  };
-
-  // decrement session time to the minimum (1 minute)
-  Array(60).fill('session-decrement').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-  // start the 25 + 5 clock
-  Array.of('start_stop').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-    if (key && typeof key.click === 'function') {
-      key.click();
-    }
-  });
-
-  // wait while timer reaches 00:00
-  await new Promise((resolve, reject) => {
-    var observer = new MutationObserver(() => {
-      if (/^00[\.:,\/]00$/.test(timeLeftElement.innerText)) {
-        observer.disconnect();
-        resolve();
-      }
-    });
-    // pass in the timeLeftElement node, as well as the observer options
-    observer.observe(timeLeftElement, {
-      childList: true,
-      characterData: true,
-      subtree: true
-    });
-  });
-
-  await timeout(1500);
-
-  window.setTimeout = savedSetTimeout;
-  window.setInterval = savedSetInterval;
-
-  await timeout(200);
-
-  assert.isFalse(
-    document.getElementById('beep').paused,
-    'Timer has reached zero but audio is not playing while it should.'
-  );
-})();
-```
-
-The audio element with `id="beep"` must be 1 second or longer.
-
-```js
-(async () => {
-  const audio = document.querySelector('audio#beep');
-  assert.isNotNull(
-    audio,
-    'There is no audio tag with ID "beep" on the page.'
-  );
-
-  if (audio.readyState === 0) {
-    // Wait for the audio to load.
-    await new Promise((resolve) => {
-      const listener = audio.addEventListener('loadeddata', () => {
-        if (audio.readyState > 0) {
-          audio.removeEventListener('loadeddata', listener);
-          resolve();
-        }
-      });
-    });
-  }
-
-  assert.isAbove(
-    document.getElementById('beep').duration,
-    1,
-    'Audio element with id="beep" is not at least 1 second long.'
-  );
-})();
-```
-
-The audio element with id of `beep` must stop playing and be rewound to the beginning when the element with the id of `reset` is clicked.
-
-```js
-// Call document.getElementById('beep') each time to overcome framework cache
-document.getElementById('beep').play();
-Array.of('reset').map((buttonId) => document.getElementById(buttonId)).forEach((key) => {
-  if (key && typeof key.click === 'function') {
-    key.click();
-  }
-});
-
-assert.isTrue(
-  document.getElementById('beep').paused,
-  'Audio element was not stopped when reset was clicked.'
-);
-
-assert.strictEqual(
-  document.getElementById('beep').currentTime,
-  0,
-  'Audio element was not rewound when reset was clicked. HINT: use ' +
-    'the currentTime property of the audio element to rewind.'
-);
-```
-
-# --seed--
-
-## --seed-contents--
-
-```html
-
-```
-
-```css
-
-```
 
 # --solutions--
 

From 98409f025d3b8d9feebb93391dc080964b69d7eb Mon Sep 17 00:00:00 2001
From: Vasilica Olariu 
Date: Thu, 1 Sep 2022 17:14:40 +0300
Subject: [PATCH 23/23] TCA-384 - update support mail for offline warning

---
 client/src/components/OfflineWarning/offline-warning.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/client/src/components/OfflineWarning/offline-warning.tsx b/client/src/components/OfflineWarning/offline-warning.tsx
index 42d3605a282f5f..ceafc25ce31554 100644
--- a/client/src/components/OfflineWarning/offline-warning.tsx
+++ b/client/src/components/OfflineWarning/offline-warning.tsx
@@ -29,7 +29,7 @@ function OfflineWarning({
       t('misc.offline')
     ) : (
       
-        placeholder
+        placeholder
       
     );
     timeout();