diff --git a/.freeCodeCamp/client/components/controls.tsx b/.freeCodeCamp/client/components/controls.tsx index 044d6243..2d2242c5 100644 --- a/.freeCodeCamp/client/components/controls.tsx +++ b/.freeCodeCamp/client/components/controls.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { F, ProjectI, TestType } from '../types'; +import { F, LoaderT, ProjectI, TestType } from '../types'; interface ControlsProps { cancelTests: F; @@ -7,6 +7,26 @@ interface ControlsProps { resetProject?: F; isResetEnabled?: ProjectI['isResetEnabled']; tests: TestType[]; + loader?: LoaderT; +} + +// Changes the Reset button background to a filling progress bar when the seed is running +function progressStyle(loader?: LoaderT) { + if (!loader) { + return {}; + } + + const { + isLoading, + progress: { total, count } + } = loader; + if (isLoading) { + return { + background: `linear-gradient(to right, #0065A9 ${ + (count / total) * 100 + }%, rgba(0,0,0,0) 0%)` + }; + } } export const Controls = ({ @@ -14,7 +34,8 @@ export const Controls = ({ runTests, resetProject, isResetEnabled, - tests + tests, + loader }: ControlsProps) => { const [isTestsRunning, setIsTestsRunning] = useState(false); @@ -34,6 +55,8 @@ export const Controls = ({ } } + const resetDisabled = !isResetEnabled || loader?.isLoading; + return (
{resetProject && (
); }; diff --git a/.freeCodeCamp/client/components/progress.tsx b/.freeCodeCamp/client/components/progress.tsx new file mode 100644 index 00000000..78a697fd --- /dev/null +++ b/.freeCodeCamp/client/components/progress.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +type ProgressProps = { + total: number; + count: number; +}; + +export function Progress({ total, count }: ProgressProps) { + const [value, setValue] = useState(0.0); + + useEffect(() => { + setValue(count / total); + }, [count]); + + return ( + + ); +} diff --git a/.freeCodeCamp/client/index.tsx b/.freeCodeCamp/client/index.tsx index bcc41c32..801146eb 100644 --- a/.freeCodeCamp/client/index.tsx +++ b/.freeCodeCamp/client/index.tsx @@ -4,6 +4,7 @@ import { ConsoleError, Events, FreeCodeCampConfigI, + LoaderT, ProjectI, TestType } from './types/index'; @@ -34,7 +35,10 @@ const App = () => { const [tests, setTests] = useState([]); const [hints, setHints] = useState([]); const [cons, setCons] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [loader, setLoader] = useState({ + isLoading: false, + progress: { count: 0, total: 1 } + }); const [alertCamper, setAlertCamper] = useState(null); const [error, setError] = useState(null); @@ -77,7 +81,7 @@ const App = () => { const handle = { 'handle-project-finish': handleProjectFinish, - 'toggle-loader-animation': toggleLoaderAnimation, + 'update-loader': updateLoader, 'update-test': updateTest, 'update-tests': updateTests, 'update-hints': updateHints, @@ -161,6 +165,10 @@ const App = () => { setError(error); } + function updateLoader({ loader }: { loader: LoaderT }) { + setLoader(loader); + } + function resetTests() { setTests([]); } @@ -171,8 +179,8 @@ const App = () => { setCons([]); } - function toggleLoaderAnimation() { - setIsLoading(prev => !prev); + function toggleLoaderAnimation({ loader }: { loader: LoaderT }) { + setLoader(loader); } function runTests() { @@ -215,7 +223,7 @@ const App = () => { goToNextLesson, goToPreviousLesson, hints, - isLoading, + loader, lessonNumber, project, resetProject, diff --git a/.freeCodeCamp/client/styles.css b/.freeCodeCamp/client/styles.css index b99477be..0ef878aa 100644 --- a/.freeCodeCamp/client/styles.css +++ b/.freeCodeCamp/client/styles.css @@ -92,19 +92,26 @@ p { /* get rid of bad outlines */ background: conic-gradient(#0000, var(--c)) content-box; -webkit-mask: - /* we use +/-1deg between colors to avoid jagged edges */ - repeating-conic-gradient(#0000 0deg, + /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient( + #0000 0deg, #000 1deg calc(360deg / var(--n) - var(--g) - 1deg), - #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))), - radial-gradient(farthest-side, + #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n)) + ), + radial-gradient( + farthest-side, #0000 calc(98% - var(--b)), - #000 calc(100% - var(--b))); - mask: repeating-conic-gradient(#0000 0deg, + #000 calc(100% - var(--b)) + ); + mask: repeating-conic-gradient( + #0000 0deg, #000 1deg calc(360deg / var(--n) - var(--g) - 1deg), - #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))), - radial-gradient(farthest-side, + #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n)) + ), + radial-gradient( + farthest-side, #0000 calc(98% - var(--b)), - #000 calc(100% - var(--b))); + #000 calc(100% - var(--b)) + ); -webkit-mask-composite: destination-in; mask-composite: intersect; animation: load 1s infinite steps(var(--n)); @@ -146,6 +153,7 @@ p { transform: rotate(1turn); } } + .hidden { display: none; } @@ -245,4 +253,4 @@ details { color: var(--light-2); background-color: var(--dark-3); cursor: pointer; -} \ No newline at end of file +} diff --git a/.freeCodeCamp/client/templates/project.tsx b/.freeCodeCamp/client/templates/project.tsx index 9f424ae5..331fae6d 100644 --- a/.freeCodeCamp/client/templates/project.tsx +++ b/.freeCodeCamp/client/templates/project.tsx @@ -1,6 +1,6 @@ import { Description } from '../components/description'; import { Heading } from '../components/heading'; -import { ConsoleError, F, ProjectI, TestType } from '../types'; +import { ConsoleError, F, LoaderT, ProjectI, TestType } from '../types'; import { Controls } from '../components/controls'; import { Output } from '../components/output'; import './project.css'; @@ -14,7 +14,7 @@ export interface ProjectProps { cons: ConsoleError[]; description: string; hints: string[]; - isLoading: boolean; + loader: LoaderT; lessonNumber: number; project: ProjectI; tests: TestType[]; @@ -26,7 +26,7 @@ export const Project = ({ resetProject, goToNextLesson, goToPreviousLesson, - isLoading, + loader, project, lessonNumber, description, @@ -63,11 +63,12 @@ export const Project = ({ runTests, resetProject, isResetEnabled: project.isResetEnabled, - tests + tests, + loader })} /> - + ); diff --git a/.freeCodeCamp/client/types/index.ts b/.freeCodeCamp/client/types/index.ts index 1a909441..2ad7728a 100644 --- a/.freeCodeCamp/client/types/index.ts +++ b/.freeCodeCamp/client/types/index.ts @@ -27,6 +27,14 @@ export type TestType = { testId: number; }; +export type LoaderT = { + isLoading: boolean; + progress: { + total: number; + count: number; + }; +}; + export interface ProjectI { id: number; title: string; diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js index 5a9696b6..88062d8f 100644 --- a/.freeCodeCamp/plugin/index.js +++ b/.freeCodeCamp/plugin/index.js @@ -76,6 +76,11 @@ export const pluginEvents = { */ onLessonFailed: async project => {}, + /** + * @param {Project} project + */ + onLessonLoad: async project => {}, + /** * @param {string} projectDashedName * @returns {Promise<{title: string; description: string; numberOfLessons: number; tags: string[]}>} diff --git a/.freeCodeCamp/tooling/client-socks.js b/.freeCodeCamp/tooling/client-socks.js index 39c121ff..e44025fc 100644 --- a/.freeCodeCamp/tooling/client-socks.js +++ b/.freeCodeCamp/tooling/client-socks.js @@ -1,7 +1,7 @@ import { parseMarkdown } from './parser.js'; -export function toggleLoaderAnimation(ws) { - ws.send(parse({ event: 'toggle-loader-animation' })); +export function updateLoader(ws, loader) { + ws.send(parse({ event: 'update-loader', data: { loader } })); } /** diff --git a/.freeCodeCamp/tooling/hot-reload.js b/.freeCodeCamp/tooling/hot-reload.js index e918ad2c..43016d4f 100644 --- a/.freeCodeCamp/tooling/hot-reload.js +++ b/.freeCodeCamp/tooling/hot-reload.js @@ -29,6 +29,11 @@ export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) { let testsRunning = false; let isClearConsole = false; + // hotReload is called on connection, which can happen mulitple times due to client reload/disconnect. + // This ensures the following does not happen: + // > MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 all listeners added to [FSWatcher]. + watcher.removeAllListeners('all'); + watcher.on('all', async (event, name) => { if (name && !pathsToIgnore.find(p => name.includes(p))) { if (isWait) return; diff --git a/.freeCodeCamp/tooling/lesson.js b/.freeCodeCamp/tooling/lesson.js index 66c5933e..eb7763eb 100644 --- a/.freeCodeCamp/tooling/lesson.js +++ b/.freeCodeCamp/tooling/lesson.js @@ -31,6 +31,7 @@ export async function runLesson(ws, projectDashedName) { if (currentLesson === 0) { await pluginEvents.onProjectStart(project); } + await pluginEvents.onLessonLoad(project); updateProject(ws, project); diff --git a/.freeCodeCamp/tooling/reset.js b/.freeCodeCamp/tooling/reset.js index 295e8fa2..d9a0374c 100644 --- a/.freeCodeCamp/tooling/reset.js +++ b/.freeCodeCamp/tooling/reset.js @@ -1,5 +1,5 @@ // Handles all the resetting of the projects -import { resetBottomPanel, updateError } from './client-socks.js'; +import { resetBottomPanel, updateError, updateLoader } from './client-socks.js'; import { getProjectConfig, getState } from './env.js'; import { logover } from './logger.js'; import { runCommand, runLessonSeed } from './seed.js'; @@ -15,21 +15,36 @@ export async function resetProject(ws) { const { currentProject } = await getState(); const project = await getProjectConfig(currentProject); const { currentLesson } = project; + updateLoader(ws, { + isLoading: true, + progress: { total: currentLesson, count: 0 } + }); let lessonNumber = 0; try { await gitResetCurrentProjectDir(); while (lessonNumber <= currentLesson) { - const { seed } = pluginEvents.getLesson(currentProject, lessonNumber); + const { seed } = await pluginEvents.getLesson( + currentProject, + lessonNumber + ); if (seed) { - await runLessonSeed(seed, currentProject, lessonNumber); + await runLessonSeed(seed, lessonNumber); } lessonNumber++; + updateLoader(ws, { + isLoading: true, + progress: { total: currentLesson, count: lessonNumber } + }); } } catch (err) { updateError(ws, err); logover.error(err); } + updateLoader(ws, { + isLoading: false, + progress: { total: 1, count: 1 } + }); } async function gitResetCurrentProjectDir() { diff --git a/.freeCodeCamp/tooling/seed.js b/.freeCodeCamp/tooling/seed.js index e5caf69c..47851c26 100644 --- a/.freeCodeCamp/tooling/seed.js +++ b/.freeCodeCamp/tooling/seed.js @@ -11,7 +11,7 @@ import { writeFile } from 'fs/promises'; import { promisify } from 'util'; import { exec } from 'child_process'; import { logover } from './logger.js'; -import { updateError } from './client-socks.js'; +import { updateLoader, updateError } from './client-socks.js'; import { watcher } from './hot-reload.js'; import { pluginEvents } from '../plugin/index.js'; const execute = promisify(exec); @@ -22,7 +22,10 @@ const execute = promisify(exec); * @param {string} projectDashedName */ export async function seedLesson(ws, projectDashedName) { - // TODO: Use ws to display loader whilst seeding + updateLoader(ws, { + isLoading: true, + progress: { total: 2, count: 1 } + }); const project = await getProjectConfig(projectDashedName); const { currentLesson } = project; @@ -43,6 +46,7 @@ export async function seedLesson(ws, projectDashedName) { updateError(ws, e); logover.error(e); } + updateLoader(ws, { isLoading: false, progress: { total: 1, count: 1 } }); } /** diff --git a/cli/src/clapper.rs b/cli/src/clapper.rs index f60b5f67..d72e8dbc 100644 --- a/cli/src/clapper.rs +++ b/cli/src/clapper.rs @@ -96,14 +96,14 @@ pub fn add_project() -> InquireResult<()> { id, dashed_name, current_lesson: 0, - is_integrated: Some(is_integrated), - is_public: Some(is_public), - run_tests_on_watch: Some(run_tests_on_watch), - seed_every_lesson: Some(seed_every_lesson), - is_reset_enabled: Some(is_reset_enabled), - blocking_tests: Some(blocking_tests), - break_on_failure: Some(break_on_failure), - number_of_lessons: None, + is_integrated, + is_public, + run_tests_on_watch, + seed_every_lesson, + is_reset_enabled, + blocking_tests, + break_on_failure, + number_of_lessons: 1, }; projects.push(project); create_project_metadata(&freecodecamp_conf, &projects); diff --git a/cli/src/conf.rs b/cli/src/conf.rs index 698a3822..89448cae 100644 --- a/cli/src/conf.rs +++ b/cli/src/conf.rs @@ -116,24 +116,40 @@ pub struct Project { pub id: u16, #[serde(rename = "dashedName")] pub dashed_name: String, - #[serde(rename = "isIntegrated")] - pub is_integrated: Option, - #[serde(rename = "isPublic")] - pub is_public: Option, - #[serde(rename = "currentLesson")] + #[serde(rename = "isIntegrated", default = "default_false")] + pub is_integrated: bool, + #[serde(rename = "isPublic", default = "default_true")] + pub is_public: bool, + #[serde(rename = "currentLesson", default = "default_0")] pub current_lesson: u16, - #[serde(rename = "runTestsOnWatch")] - pub run_tests_on_watch: Option, - #[serde(rename = "seedEveryLesson")] - pub seed_every_lesson: Option, - #[serde(rename = "isResetEnabled")] - pub is_reset_enabled: Option, - #[serde(rename = "numberofLessons")] - pub number_of_lessons: Option, - #[serde(rename = "blockingTests")] - pub blocking_tests: Option, - #[serde(rename = "breakOnFailure")] - pub break_on_failure: Option, + #[serde(rename = "runTestsOnWatch", default = "default_false")] + pub run_tests_on_watch: bool, + #[serde(rename = "seedEveryLesson", default = "default_false")] + pub seed_every_lesson: bool, + #[serde(rename = "isResetEnabled", default = "default_false")] + pub is_reset_enabled: bool, + #[serde(rename = "numberofLessons", default = "default_1")] + pub number_of_lessons: u16, + #[serde(rename = "blockingTests", default = "default_false")] + pub blocking_tests: bool, + #[serde(rename = "breakOnFailure", default = "default_false")] + pub break_on_failure: bool, +} + +fn default_false() -> bool { + false +} + +fn default_true() -> bool { + true +} + +fn default_1() -> u16 { + 1 +} + +fn default_0() -> u16 { + 0 } #[derive(Serialize, Deserialize, Debug)] diff --git a/cli/src/fs.rs b/cli/src/fs.rs index e6647a84..d8f6ef9a 100644 --- a/cli/src/fs.rs +++ b/cli/src/fs.rs @@ -491,15 +491,15 @@ pluginEvents.onLessonPassed = async project => {}; let project = Project { id: u16::from(i), dashed_name: format!("project-{i}"), - is_integrated: Some(false), - is_public: Some(true), + is_integrated: false, + is_public: true, current_lesson: 0, - run_tests_on_watch: Some(true), - seed_every_lesson: Some(false), - is_reset_enabled: Some(false), - number_of_lessons: None, - blocking_tests: None, - break_on_failure: None, + run_tests_on_watch: true, + seed_every_lesson: false, + is_reset_enabled: false, + number_of_lessons: 1, + blocking_tests: false, + break_on_failure: false, }; projects.push(project); } diff --git a/cli/src/main.rs b/cli/src/main.rs index be3688a4..428e10b2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,5 @@ #![warn(clippy::pedantic)] +#![allow(clippy::struct_excessive_bools)] use clap::Parser; use clapper::{add_project, create_course, Cli, SubCommand}; diff --git a/docs/src/CHANGELOG.md b/docs/src/CHANGELOG.md index bbb8bbe0..46043dda 100644 --- a/docs/src/CHANGELOG.md +++ b/docs/src/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.3.0] - 2024-02-05 + +### Add + +- `pluginEvents.onLessonLoad` +- Loader showing progress for reset step + ## [3.2.0] - 2024-02-12 ### Add diff --git a/docs/src/plugin-system.md b/docs/src/plugin-system.md index d764f24a..0d7626d5 100644 --- a/docs/src/plugin-system.md +++ b/docs/src/plugin-system.md @@ -30,6 +30,10 @@ Called when a lesson passes, after all tests are run **and** passed, and only ha Called when a lesson fails, after all tests are run **and** any fail. +### `onLessonLoad` + +Called once when a lesson is loaded, after the `onProjectStart` if the first lesson. + ## Parser It is possible to define a custom parser for the curriculum files. This is useful when the curriculum files are not in the default format described in the [project syntax](./project-syntax.md) section. diff --git a/self/config/projects.json b/self/config/projects.json index 92d5f895..94f68e1c 100644 --- a/self/config/projects.json +++ b/self/config/projects.json @@ -4,7 +4,7 @@ "dashedName": "learn-freecodecamp-os", "isIntegrated": false, "isPublic": true, - "currentLesson": 0, + "currentLesson": 1, "runTestsOnWatch": true, "seedEveryLesson": false, "isResetEnabled": true, @@ -40,5 +40,19 @@ "blockingTests": false, "breakOnFailure": false, "numberOfLessons": 2 + }, + { + "id": 3, + "dashedName": "project-reset", + "isIntegrated": false, + "isPublic": true, + "currentLesson": 0, + "runTestsOnWatch": false, + "seedEveryLesson": false, + "isResetEnabled": true, + "numberofLessons": null, + "blockingTests": false, + "breakOnFailure": false, + "numberOfLessons": 4 } ] diff --git a/self/curriculum/locales/english/project-reset.md b/self/curriculum/locales/english/project-reset.md new file mode 100644 index 00000000..c424290b --- /dev/null +++ b/self/curriculum/locales/english/project-reset.md @@ -0,0 +1,109 @@ +# Project Reset + +This project tests the reset functionality of `freecodecamp-os` + +## 0 + +### --description-- + +The first lesson does not necessarily need to have a seed, because, on reset, `git clean -f -q -- ` is run. + +### --hints-- + +#### 0 + +**Note:** `git clean` only works if Campers have not committed any changes. Otherwise, it is best to write a custom seed command for the first lesson. + +### --tests-- + +This test always passes for testing. + +```js +await new Promise(resolve => setTimeout(resolve, 1000)); +``` + +## 1 + +### --description-- + +This lesson's seed adds the `a.md` file, and runs a command which takes 2 seconds to complete. + +### --tests-- + +This test always passes for testing. + +```js +await new Promise(resolve => setTimeout(resolve, 1000)); +``` + +### --seed-- + +#### --"project-reset/a.md"-- + +```md +File from lesson 1 +``` + +#### --cmd-- + +```bash +echo "Lesson 1" && sleep 2 +``` + +## 2 + +### --description-- + +This lesson's seed adds the `b.md` file, and runs a command which takes 2 seconds to complete. + +### --tests-- + +This test always passes for testing. + +```js +await new Promise(resolve => setTimeout(resolve, 1000)); +``` + +### --seed-- + +#### --"project-reset/b.md"-- + +```md +File from lesson 2 +``` + +#### --cmd-- + +```bash +echo "Lesson 2" && sleep 2 +``` + +## 3 + +### --description-- + +This lesson's seed adds the `c.md` file, and runs a command which takes 2 seconds to complete. + +### --tests-- + +This test always passes for testing. + +```js +await new Promise(resolve => setTimeout(resolve, 1000)); +``` + +### --seed-- + +#### --"project-reset/c.md"-- + +```md +File from lesson 3 +``` + +#### --cmd-- + +```bash +echo "Lesson 3" && sleep 2 +``` + +## --fcc-end-- diff --git a/self/package-lock.json b/self/package-lock.json index c4909edc..ea52bfda 100644 --- a/self/package-lock.json +++ b/self/package-lock.json @@ -1,19 +1,19 @@ { "name": "self", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "self", - "version": "3.2.0", + "version": "3.3.0", "dependencies": { "@freecodecamp/freecodecamp-os": "../" } }, "..": { "name": "@freecodecamp/freecodecamp-os", - "version": "3.2.0", + "version": "3.3.0", "dependencies": { "chai": "4.4.1", "chokidar": "3.6.0", diff --git a/self/package.json b/self/package.json index b969feed..426b841a 100644 --- a/self/package.json +++ b/self/package.json @@ -2,7 +2,7 @@ "name": "self", "private": true, "author": "freeCodeCamp", - "version": "3.2.0", + "version": "3.3.0", "description": "Test repo for @freecodecamp/freecodecamp-os", "scripts": { "start": "node ./node_modules/@freecodecamp/freecodecamp-os/.freeCodeCamp/tooling/server.js" diff --git a/self/project-reset/.gitkeep b/self/project-reset/.gitkeep new file mode 100644 index 00000000..e69de29b