diff --git a/Sprint-3/todo-list/00-what_is_ES_modules.md b/Sprint-3/todo-list/00-what_is_ES_modules.md new file mode 100644 index 000000000..c10214863 --- /dev/null +++ b/Sprint-3/todo-list/00-what_is_ES_modules.md @@ -0,0 +1,16 @@ +# What is JavaScript Modules? +JavaScript modules let us organize code into separate files, making the code easier to manage and reuse. We can export parts of one file (like functions or variables) and import them into another to keep our code clean and modular. + +## Different Module Systems + +JavaScript has two main ways to handle modules: + +- **CommonJS** is the older format used mostly in Node.js. It uses `require` and `module.exports`. + +- **ES Modules** (ESM) are the modern, standard way for browsers and Node.js. They use `import` and `export`. + +They are **not** fully compatible. + +## Where can I learn ESM? +- [Modules, introduction](https://javascript.info/modules-intro) +- [JavaScript modules -- JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) \ No newline at end of file diff --git a/Sprint-3/todo-list/01-using_esm_with_nodejs_and_jest.md b/Sprint-3/todo-list/01-using_esm_with_nodejs_and_jest.md new file mode 100644 index 000000000..3f1090ab8 --- /dev/null +++ b/Sprint-3/todo-list/01-using_esm_with_nodejs_and_jest.md @@ -0,0 +1,47 @@ +# Using ESM with Node.js, Jest, and `jsdom` + +## Node.js + +Node.js supports both CommonJS and ESM, with CommonJS being the default module system. + +To use ESM, we can add `"type": "module"` to `package.json`, or we can name the JS script files with `.mjs` file extension. + +**Important**: +- Avoid mixing CommonJS and ESM in the same project unless you know what you're doing. + + +## Jest + +[Jest’s support for ESM](https://jestjs.io/docs/ecmascript-modules) is still experimental, and may require additional configuration to work correctly. + +One way to execute Jest test script that uses ESM is to + +1. **Update the custom `test` script in `package.json`**: +```javascript +"scripts": { + "test": "NODE_OPTIONS=--experimental-vm-modules jest" +} +``` + +**Note**: On Windows, use **`set`** instead +```javascript +"scripts": { + "test": "set NODE_OPTIONS=--experimental-vm-modules && jest" +} +``` + + +2. **Run a specific Jest test script using**: + +``` +npm test -- +``` + +**Note**: The `--` is optional if you do not have arguments to be forwarded to the underlying jest command. + + +## `jsdom` + +[**`jsdom`**](https://github.com/jsdom/jsdom), a pure-JavaScript implementation of DOM for use with Node.js, **does not yet support** ` - + + + + ToDo List + + + + + + +
+

My ToDo List

+ +
+ + +
+ + + + + + +
+ diff --git a/Sprint-3/todo-list/package.json b/Sprint-3/todo-list/package.json index b25383cf8..88cf5d4af 100644 --- a/Sprint-3/todo-list/package.json +++ b/Sprint-3/todo-list/package.json @@ -3,8 +3,9 @@ "version": "1.0.0", "license": "CC-BY-SA-4.0", "description": "You must update this package", + "type": "module", "scripts": { - "test": "jest --config=../jest.config.js todo-list" + "test": "NODE_OPTIONS=--experimental-vm-modules jest" }, "repository": { "type": "git", @@ -13,5 +14,8 @@ "bugs": { "url": "https://github.com/CodeYourFuture/CYF-Coursework-Template/issues" }, - "homepage": "https://github.com/CodeYourFuture/CYF-Coursework-Template#readme" + "homepage": "https://github.com/CodeYourFuture/CYF-Coursework-Template#readme", + "devDependencies": { + "jest": "^30.0.4" + } } diff --git a/Sprint-3/todo-list/package.json-windows b/Sprint-3/todo-list/package.json-windows new file mode 100644 index 000000000..c51e4327d --- /dev/null +++ b/Sprint-3/todo-list/package.json-windows @@ -0,0 +1,21 @@ +{ + "name": "todo-list", + "version": "1.0.0", + "license": "CC-BY-SA-4.0", + "description": "You must update this package", + "type": "module", + "scripts": { + "test": "set NODE_OPTIONS=--experimental-vm-modules && jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/CodeYourFuture/CYF-Coursework-Template.git" + }, + "bugs": { + "url": "https://github.com/CodeYourFuture/CYF-Coursework-Template/issues" + }, + "homepage": "https://github.com/CodeYourFuture/CYF-Coursework-Template#readme", + "devDependencies": { + "jest": "^30.0.4" + } +} diff --git a/Sprint-3/todo-list/script.js b/Sprint-3/todo-list/script.js deleted file mode 100644 index 61982a54f..000000000 --- a/Sprint-3/todo-list/script.js +++ /dev/null @@ -1,25 +0,0 @@ -function populateTodoList(todos) { - let list = document.getElementById("todo-list"); - // Write your code to create todo list elements with completed and delete buttons here, all todos should display inside the "todo-list" element. -} - -// These are the same todos that currently display in the HTML -// You will want to remove the ones in the current HTML after you have created them using JavaScript -let todos = [ - { task: "Wash the dishes", completed: false }, - { task: "Do the shopping", completed: false }, -]; - -populateTodoList(todos); - -// This function will take the value of the input field and add it as a new todo to the bottom of the todo list. These new todos will need the completed and delete buttons adding like normal. -function addNewTodo(event) { - // The code below prevents the page from refreshing when we click the 'Add Todo' button. - event.preventDefault(); - // Write your code here... and remember to reset the input field to be blank after creating a todo! -} - -// Advanced challenge: Write a fucntion that checks the todos in the todo list and deletes the completed ones (we can check which ones are completed by seeing if they have the line-through styling applied or not). -function deleteAllCompletedTodos() { - // Write your code here... -} diff --git a/Sprint-3/todo-list/script.mjs b/Sprint-3/todo-list/script.mjs new file mode 100644 index 000000000..8e53b9703 --- /dev/null +++ b/Sprint-3/todo-list/script.mjs @@ -0,0 +1,69 @@ +// Note: Todos will be an object containing all the named exports from +// the "./todos.mjs" module. +import * as Todos from "./todos.mjs"; + +// To store the todo tasks +let todos = []; + +// First child of #todo-item-template is a
  • element. +// We will create each ToDo list item as a clone of this node. +const todoListItemTemplate = + document.getElementById("todo-item-template").content.firstElementChild; + +const todoListEl = document.getElementById("todo-list"); + + +window.addEventListener("load", () => { + document.getElementById("add-task-btn").addEventListener("click", addNewTodo); + + // Populate sample data + Todos.addTask(todos, "Wash the dishes", false); + Todos.addTask(todos, "Do the shopping", true); + + render(); +}); + + +// A callback that reads the task description from an input field and +// append a new task to the todo list. +function addNewTodo() { + const taskInput = document.getElementById("new-task-input"); + const task = taskInput.value.trim(); + if (task) { + Todos.addTask(todos, task, false); + render(); + } + + taskInput.value = ""; +} + +// Render the whole todo list +function render() { + todoListEl.innerHTML = ""; + + todos.forEach((todo, index) => { + const todoListItem = createListItem(todo, index); + todoListEl.append(todoListItem); + }); +} + +// Create a
  • element for the given todo task +function createListItem(todo, index) { + const li = todoListItemTemplate.cloneNode(true); // true => Do a deep copy of the node + + li.querySelector(".description").textContent = todo.task; + if (todo.completed) + li.classList.add("completed"); + + li.querySelector('.complete-btn').addEventListener("click", () => { + Todos.toggleCompletedOnTask(todos, index); + render(); + }); + + li.querySelector('.delete-btn').addEventListener("click", () => { + Todos.deleteTask(todos, index); + render(); + }); + + return li; +} \ No newline at end of file diff --git a/Sprint-3/todo-list/script.test.js b/Sprint-3/todo-list/script.test.js deleted file mode 100644 index 13c897bf0..000000000 --- a/Sprint-3/todo-list/script.test.js +++ /dev/null @@ -1,162 +0,0 @@ -const path = require("path"); -const { JSDOM } = require("jsdom"); -const { default: userEvent } = require("@testing-library/user-event"); - -let page = null; - -beforeEach(async () => { - page = await JSDOM.fromFile(path.join(__dirname, "index.html"), { - resources: "usable", - runScripts: "dangerously", - }); - - // do this so students can use element.innerText which jsdom does not implement - Object.defineProperty(page.window.HTMLElement.prototype, "innerText", { - get() { - return this.textContent; - }, - set(value) { - this.textContent = value; - }, - }); - - return new Promise((res) => { - page.window.document.addEventListener("load", res); - }); -}); - -afterEach(() => { - page = null; -}); - -describe("Mandatory tasks", () => { - test("displays the initial list of todos", () => { - const todoList = page.window.document.querySelector("#todo-list"); - const listItems = [...page.window.document.querySelectorAll("li")]; - - expect(todoList).toHaveTextContent("Wash the dishes"); - expect(todoList).toHaveTextContent("Do the shopping"); - expect(listItems.length).toBe(2); - }); - - test("each todo has a delete and tick icon", () => { - const listItems = [...page.window.document.querySelectorAll("li")]; - - listItems.forEach((_, index) => { - const tickIcon = page.window.document.querySelector( - `li:nth-child(${index + 1}) i.fa-check` - ); - const binIcon = page.window.document.querySelector( - `li:nth-child(${index + 1}) i.fa-trash` - ); - - expect(tickIcon).toBeInTheDocument(); - expect(binIcon).toBeInTheDocument(); - }); - }); - - test("can add a new todo to the list", () => { - const todoList = page.window.document.querySelector("#todo-list"); - const button = page.window.document.querySelector(".btn"); - const input = page.window.document.querySelector("#todoInput"); - const todoText = "Do CYF coursework"; - - userEvent.type(input, todoText); - userEvent.click(button); - - expect(todoList).toHaveTextContent(todoText); - - const listItems = [...page.window.document.querySelectorAll("li")]; - expect(listItems.length).toBe(3); - }); - - test("can strike through a todo when it is completed", () => { - const li = page.window.document.querySelector("li"); - const tickIcon = page.window.document.querySelector("li i"); - - userEvent.click(tickIcon); - - expect(li).toHaveStyle({ - textDecoration: "line-through", - }); - }); - - test("can undo a strikethrough on a todo", () => { - const li = page.window.document.querySelector("li"); - const tickIcon = page.window.document.querySelector("li i"); - userEvent.click(tickIcon); - - expect(li).toHaveStyle({ - textDecoration: "line-through", - }); - - userEvent.click(tickIcon); - - expect(li).not.toHaveStyle({ - textDecoration: "line-through", - }); - }); - - test("can delete a todo from the list", () => { - const todoList = page.window.document.querySelector("#todo-list"); - const button = page.window.document.querySelector(".btn"); - const input = page.window.document.querySelector("#todoInput"); - const todoText = "Do CYF coursework"; - - userEvent.type(input, todoText); - userEvent.click(button); - - expect(todoList).toHaveTextContent(todoText); - const listItems = [...page.window.document.querySelectorAll("li")]; - expect(listItems.length).toBe(3); - - const binIcon = page.window.document.querySelector( - "li:nth-child(3) i.fa-trash" - ); - userEvent.click(binIcon); - - expect(todoList).not.toHaveTextContent(todoText); - }); -}); - -describe("Advanced tasks", () => { - test("can remove all completed todos", () => { - const todoList = page.window.document.querySelector("#todo-list"); - const button = page.window.document.querySelector(".btn"); - const input = page.window.document.querySelector("#todoInput"); - - userEvent.type(input, "Do CYF coursework"); - userEvent.click(button); - - userEvent.clear(input); - userEvent.type(input, "Make a sandwich"); - userEvent.click(button); - - userEvent.clear(input); - userEvent.type(input, "Take a break"); - userEvent.click(button); - - const tickIcon2 = page.window.document.querySelector( - "li:nth-child(2) i.fa-check" - ); - userEvent.click(tickIcon2); - const tickIcon4 = page.window.document.querySelector( - "li:nth-child(4) i.fa-check" - ); - userEvent.click(tickIcon4); - - const removeAllCompletedButton = page.window.document.querySelector( - "#remove-all-completed" - ); - userEvent.click(removeAllCompletedButton); - - const listItems = [...page.window.document.querySelectorAll("li")]; - expect(listItems.length).toBe(3); - expect(todoList).toHaveTextContent("Wash the dishes"); - expect(todoList).toHaveTextContent("Do CYF coursework"); - expect(todoList).toHaveTextContent("Take a break"); - - expect(todoList).not.toHaveTextContent("Do the shopping"); - expect(todoList).not.toHaveTextContent("Make a sandwich"); - }); -}); diff --git a/Sprint-3/todo-list/style.css b/Sprint-3/todo-list/style.css index 8b1378917..535e91227 100644 --- a/Sprint-3/todo-list/style.css +++ b/Sprint-3/todo-list/style.css @@ -1 +1,107 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: Arial, sans-serif; +} +body { + background-color: #f4f4f4; + padding: 40px; +} + +.todo-container { + max-width: 500px; + margin: 0 auto; + background: white; + border-radius: 10px; + padding: 20px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); +} + +h1 { + text-align: center; + margin-bottom: 20px; +} + +.todo-input { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.todo-input input { + flex: 1; + padding: 10px; + font-size: 16px; + border-radius: 6px; + border: 1px solid #ccc; +} + +.todo-input button { + padding: 10px 20px; + font-size: 16px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.todo-input button:hover { + background-color: #45a049; +} + +.todo-list { + list-style-type: none; + padding-left: 0; +} + +.todo-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 10px; + margin-bottom: 10px; + border: 1px solid #ddd; + border-radius: 6px; + background-color: #fff; +} + +.description { + flex: 1; + margin-right: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions { + display: flex; + gap: 10px; +} + +.actions button { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; +} + +.complete-btn i { + color: green; +} + +.delete-btn i { + color: red; +} + +.todo-item.completed .description { + text-decoration: line-through; + color: gray; +} diff --git a/Sprint-3/todo-list/todos.mjs b/Sprint-3/todo-list/todos.mjs new file mode 100644 index 000000000..f17ab6a25 --- /dev/null +++ b/Sprint-3/todo-list/todos.mjs @@ -0,0 +1,29 @@ +/* + A ToDo List (todos) is expected to be represented as an array of objects in + the following manner: + + [ + { task: "Description of task 1", completed: false}, + { task: "Description of task 2", completed: true} + ] + +*/ + +// Append a new task to todos[] +export function addTask(todos, task, completed = false) { + todos.push({ task, completed }); +} + +// Delete todos[taskIndex] if it exists +export function deleteTask(todos, taskIndex) { + if (todos[taskIndex]) { + todos.splice(taskIndex, 1); + } +} + +// Toggle the "completed" property of todos[taskIndex] if the task exists. +export function toggleCompletedOnTask(todos, taskIndex) { + if (todos[taskIndex]) { + todos[taskIndex].completed = !todos[taskIndex].completed; + } +} \ No newline at end of file diff --git a/Sprint-3/todo-list/todos.test.mjs b/Sprint-3/todo-list/todos.test.mjs new file mode 100644 index 000000000..3a447196c --- /dev/null +++ b/Sprint-3/todo-list/todos.test.mjs @@ -0,0 +1,134 @@ +// The tests is designed to demonstrates we can test the functions +// in a module independently. + +// Command to execute this script: +// npm test todos.test.mjs + + +// Import all the exported members through an object +import * as Todos from "./todos.mjs"; + +// Return a mock ToDo List data +// Tests in this file expect exactly 4 elements in the mocked ToDo List +function createMockTodos() { + return [ + { task: "Task 1 description", completed: true }, + { task: "Task 2 description", completed: false }, + { task: "Task 3 description", completed: true }, + { task: "Task 4 description", completed: false }, + ]; +} + +const theTask = { task: "The Task", completed: false }; + + +describe("addTask()", () => { + test("Add a task to an empty array", () => { + let todos = []; + Todos.addTask(todos, theTask.task, theTask.completed); + expect(todos).toHaveLength(1); + expect(todos[0]).toEqual(theTask); + }); + + test("Should append a task to end of an array", () => { + + const todos = createMockTodos(); + const lengthBeforeAddingTask = todos.length; + Todos.addTask(todos, theTask.task, theTask.completed); + // Array should have one more task + expect(todos).toHaveLength(lengthBeforeAddingTask + 1); + + // New task should be appended to the array + expect(todos[todos.length - 1]).toEqual(theTask); + }); +}); + +describe("deleteTask()", () => { + + test("Delete the first task", () => { + const todos = createMockTodos(); + const todosBeforeDeletion = createMockTodos(); + const lengthBeforeAddingTask = todos.length; + Todos.deleteTask(todos, 0); + + expect(todos).toHaveLength(lengthBeforeAddingTask-1); + + expect(todos[0]).toEqual(todosBeforeDeletion[1]); + expect(todos[1]).toEqual(todosBeforeDeletion[2]); + expect(todos[2]).toEqual(todosBeforeDeletion[3]); + }); + + test("Delete a middle task", () => { + const todos = createMockTodos(); + const todosBeforeDeletion = createMockTodos(); + const lengthBeforeAddingTask = todos.length; + Todos.deleteTask(todos, 1); + + expect(todos).toHaveLength(lengthBeforeAddingTask-1); + + expect(todos[0]).toEqual(todosBeforeDeletion[0]); + expect(todos[1]).toEqual(todosBeforeDeletion[2]); + expect(todos[2]).toEqual(todosBeforeDeletion[3]); + }); + + test("Delete the last task", () => { + const todos = createMockTodos(); + const todosBeforeDeletion = createMockTodos(); + const lengthBeforeAddingTask = todos.length; + Todos.deleteTask(todos, todos.length-1); + + expect(todos).toHaveLength(lengthBeforeAddingTask-1); + + expect(todos[0]).toEqual(todosBeforeDeletion[0]); + expect(todos[1]).toEqual(todosBeforeDeletion[1]); + expect(todos[2]).toEqual(todosBeforeDeletion[2]); + }); + + test("Delete a non-existing task", () => { + const todos = createMockTodos(); + const todosBeforeDeletion = createMockTodos(); + Todos.deleteTask(todos, 10); + expect(todos).toEqual(todosBeforeDeletion); + + Todos.deleteTask(todos, -1); + expect(todos).toEqual(todosBeforeDeletion); + }); +}); + +describe("toggleCompletedOnTask()", () => { + + test("Expect the 'completed' property to toggle on an existing task", () => { + const todos = createMockTodos(); + const taskIdx = 1; + const completedBeforeToggle = todos[taskIdx].completed; + Todos.toggleCompletedOnTask(todos, taskIdx); + expect(todos[taskIdx].completed).toEqual(!completedBeforeToggle); + + // Toggle again + Todos.toggleCompletedOnTask(todos, taskIdx); + expect(todos[taskIdx].completed).toEqual(completedBeforeToggle); + }); + + test("Expect toggling on an existing task does not affect other tasks", () => { + const todos = createMockTodos(); + const todosBeforeToggle = createMockTodos(); + Todos.toggleCompletedOnTask(todos, 1); + + expect(todos[0]).toEqual(todosBeforeToggle[0]); + expect(todos[2]).toEqual(todosBeforeToggle[2]); + expect(todos[3]).toEqual(todosBeforeToggle[3]); + }); + + + test("Expect no change when toggling on a non-existing task", () => { + const todos = createMockTodos(); + const todosBeforeToggle = createMockTodos(); + + Todos.toggleCompletedOnTask(todos, 10); + expect(todos).toEqual(todosBeforeToggle); + + Todos.toggleCompletedOnTask(todos, -1); + expect(todos).toEqual(todosBeforeToggle); + }); +}); +