From 2541f93fb9673581a9c041c6de9084aa3e16a71e Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Wed, 16 Apr 2025 16:53:14 +0200 Subject: [PATCH] feat(curriculum): add lab-currency-converter (#59518) Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams --- client/i18n/locales/english/intro.json | 8 +- .../lab-currency-converter/index.md | 11 + .../_meta/lab-currency-converter/meta.json | 16 + .../67eaa957114d373deb3a9149.md | 491 ++++++++++++++++++ .../superblock-structure/full-stack.json | 3 + 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/learn/full-stack-developer/lab-currency-converter/index.md create mode 100644 curriculum/challenges/_meta/lab-currency-converter/meta.json create mode 100644 curriculum/challenges/english/25-front-end-development/lab-currency-converter/67eaa957114d373deb3a9149.md diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 97d4caa4e98..d776f201e7d 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -3509,7 +3509,13 @@ ] }, "sgau": { "title": "276", "intro": [] }, - "clak": { "title": "277", "intro": [] }, + "lab-currency-converter": { + "title": "Build a Currency Converter", + "intro": [ + "For this lab, you'll build a currency converter app.", + "You'll use React state, memoization, and controlled components to convert between currencies." + ] + }, "lecture-working-with-data-fetching-and-memoization-in-react": { "title": "Working with Data Fetching and Memoization in React", "intro": [ diff --git a/client/src/pages/learn/full-stack-developer/lab-currency-converter/index.md b/client/src/pages/learn/full-stack-developer/lab-currency-converter/index.md new file mode 100644 index 00000000000..820fce4884b --- /dev/null +++ b/client/src/pages/learn/full-stack-developer/lab-currency-converter/index.md @@ -0,0 +1,11 @@ +--- +title: Introduction to the Build a Currency Converter +block: lab-currency-converter +superBlock: full-stack-developer +--- + +## Introduction to the Build a Currency Converter + +For this lab, you'll build a currency converter app. + +You'll use React state, memoization, and controlled components to convert between currencies. diff --git a/curriculum/challenges/_meta/lab-currency-converter/meta.json b/curriculum/challenges/_meta/lab-currency-converter/meta.json new file mode 100644 index 00000000000..b6fb8428721 --- /dev/null +++ b/curriculum/challenges/_meta/lab-currency-converter/meta.json @@ -0,0 +1,16 @@ +{ + "name": "Build a Currency Converter", + "usesMultifileEditor": true, + "dashedName": "lab-currency-converter", + "superBlock": "full-stack-developer", + "challengeOrder": [ + { + "id": "67eaa957114d373deb3a9149", + "title": "Build an Currency Converter" + } + ], + "helpCategory": "JavaScript", + "isUpcomingChange": false, + "blockLayout": "link", + "blockType": "lab" +} diff --git a/curriculum/challenges/english/25-front-end-development/lab-currency-converter/67eaa957114d373deb3a9149.md b/curriculum/challenges/english/25-front-end-development/lab-currency-converter/67eaa957114d373deb3a9149.md new file mode 100644 index 00000000000..594a1d38ae0 --- /dev/null +++ b/curriculum/challenges/english/25-front-end-development/lab-currency-converter/67eaa957114d373deb3a9149.md @@ -0,0 +1,491 @@ +--- +id: 67eaa957114d373deb3a9149 +title: Build a Currency Converter +challengeType: 25 +dashedName: build-a-currency-converter +demoType: onClick +--- + +# --description-- + +**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab. + +**User Stories:** + +1. Your `CurrencyConverter` component should render an `input` element to accept the amount to be converted from. +2. Your `input` element should accept numbers. +3. Your `CurrencyConverter` component should render two `select` elements to choose the currency to convert **from** and **to**. +4. Your `select` element should include options for **at least** `USD`, `EUR`, `GBP`, and `JPY`. + 1. You may use any exchange rate, provided there is no one-to-one mapping between the currencies. +5. Your `CurrencyConverter` component should memoize the calculation of the converted amounts for the **from** currency such that a change in the **to** `select` option will not recalculate the converted amounts. +6. Your `CurrencyConverter` component should render an element showing the converted amount in the format `XX.XX CCC`, where `XX.XX` is the converted amount and `CCC` is the currency code. +7. The converted amount should be rounded to two decimal places. + +# --hints-- + +You should export a `CurrencyConverter` component. + +```js +async () => { + const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src === "index.jsx").innerText; + + const exports = {}; + const a = eval(script); + + assert.property(exports, "CurrencyConverter"); +} +``` + +You should have one `input[type="number"]` element to accept the amount to be converted from. + +```js +const inp = document.querySelectorAll('input[type="number"]'); +assert.equal(inp.length, 1); +``` + +You should have two `select` elements. + +```js +const selects = document.querySelectorAll('select'); +assert.equal(selects.length, 2); +``` + +The `select` elements should have options for at least `USD`, `EUR`, `GBP`, and `JPY`. + +```js +const selects = [...document.querySelectorAll('select')]; + +assert.equal(selects.length, 2); + +for (const select of selects) { + const options = [...select.options].map((o) => o.value); + assert.includeMembers(options, ["USD", "EUR", "GBP", "JPY"]); +} +``` + +Changing the value of the first `select` element should cause the converted amounts to be recalculated. + +```js +async () => { + function spyOn( + obj, + method + ) { + const original = obj[method]; + const calls = []; + + const fn = (cb, deps) => { + const result = original(() => {calls.push(1); return cb();}, deps); + return result; + }; + + obj[method] = fn; + + fn.calls = calls; + return fn; + } + const abuseMemo = spyOn(React, "useMemo"); + const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src === "index.jsx").innerText; + + const exports = {}; + const a = eval(script); + const s = await __helpers.prepTestComponent(exports.CurrencyConverter); + const [first, _second] = s.querySelectorAll('select'); + assert.exists(first); + await React.act(async () => { + // Find first option that is not selected + const notSelected = [...first.options].find((o) => !o.selected); + first.value = notSelected.value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + first[Object.keys(first).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: first}); + }); + assert.equal(abuseMemo.calls.length, 2); +} +``` + +Changing the value of the second `select` element should not cause the converted amounts to be recalculated. + +```js +async () => { + function spyOn( + obj, + method + ) { + const original = obj[method]; + const calls = []; + + const fn = (cb, deps) => { + const result = original(() => {calls.push(1); return cb();}, deps); + return result; + }; + + obj[method] = fn; + + fn.calls = calls; + return fn; + } + const abuseMemo = spyOn(React, "useMemo"); + const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src === "index.jsx").innerText; + + const exports = {}; + const a = eval(script); + const s = await __helpers.prepTestComponent(exports.CurrencyConverter); + const [_first, second] = s.querySelectorAll('select'); + assert.exists(second); + await React.act(async () => { + // Find first option that is not selected + const notSelected = [...second.options].find((o) => !o.selected); + second.value = notSelected.value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + second[Object.keys(second).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: second}); + }); + assert.equal(abuseMemo.calls.length, 1); +} +``` + +Changing the value of the first `select` element should cause a textual change on the page. + +```js +async () => { + const s = await __helpers.prepTestComponent(window.index.CurrencyConverter); + + const nonFormContentBefore = getInnerTextExcept(s, "input,select"); + + const [first, _second] = s.querySelectorAll('select'); + assert.exists(first); + await React.act(async () => { + // Find first option that is not selected + const notSelected = [...first.options].find((o) => !o.selected); + first.value = notSelected.value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + first[Object.keys(first).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: first}); + }); + + const nonFormContentAfter = getInnerTextExcept(s, "input,select"); + + try { + assert.notEqual(nonFormContentBefore, nonFormContentAfter); + } catch (e) { + console.error(e); + throw e; + } +} +``` + +Changing the value of the second `select` element should cause a textual change on the page. + +```js +async () => { + const s = await __helpers.prepTestComponent(window.index.CurrencyConverter); + + const nonFormContentBefore = getInnerTextExcept(s, "input,select"); + + const [_first, second] = s.querySelectorAll('select'); + assert.exists(second); + await React.act(async () => { + // Find first option that is not selected + const notSelected = [...second.options].find((o) => !o.selected); + second.value = notSelected.value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + second[Object.keys(second).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: second}); + }); + + const nonFormContentAfter = getInnerTextExcept(s, "input,select"); + + try { + assert.notEqual(nonFormContentBefore, nonFormContentAfter); + } catch (e) { + console.error(e); + throw e; + } +} +``` + +The converted amount should be displayed in the format `XX.XX CCC`, where `XX.XX` is the converted amount rounded to two decimal places and `CCC` is the currency code. + +```js +async () => { + const s = await __helpers.prepTestComponent(window.index.CurrencyConverter); + + const inp = s.querySelector('input[type="number"]'); + assert.exists(inp); + const [_first, second] = s.querySelectorAll('select'); + assert.exists(second); + await React.act(async () => { + // Find first option that is not selected + const notSelected = [...second.options].find((o) => !o.selected); + second.value = notSelected.value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + second[Object.keys(second).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: second}); + inp.value = 10; + const ev2 = new Event("change", { bubbles: true, cancelable: false }); + inp[Object.keys(inp).find((k) => k.startsWith("__reactProps"))].onChange({...ev2, target: inp}); + }); + + const nonFormContent = getInnerTextExcept(s, "input,select"); + + try { + const currencyCode = second.value; + const reg = new RegExp(`\\d+\\.\\d{2} ${currencyCode}`); + assert.match(nonFormContent, reg); + } catch (e) { + console.error(e); + throw e; + } +} +``` + +The converted amount should be different from the input amount. + +```js +async () => { + const s = await __helpers.prepTestComponent(window.index.CurrencyConverter); + + const inp = s.querySelector('input[type="number"]'); + assert.exists(inp); + const [first, second] = s.querySelectorAll('select'); + assert.exists(first); + assert.exists(second); + for (let i = 0; i < first.options.length; i++) { + for (let j = 0; j < second.options.length; j++) { + if (first.options[i].value === second.options[j].value) { + continue; + } + + await React.act(async () => { + first.value = first.options[i].value; + const ev = new Event("change", { bubbles: true, cancelable: false }); + first[Object.keys(first).find((k) => k.startsWith("__reactProps"))].onChange({...ev, target: first}); + second.value = second.options[j].value; + const ev2 = new Event("change", { bubbles: true, cancelable: false }); + second[Object.keys(second).find((k) => k.startsWith("__reactProps"))].onChange({...ev2, target: second}); + + inp.value = 10; + const ev3 = new Event("change", { bubbles: true, cancelable: false }); + inp[Object.keys(inp).find((k) => k.startsWith("__reactProps"))].onChange({...ev3, target: inp}); + }); + + const nonFormContent = getInnerTextExcept(s, "input,select"); + const { amount } = nonFormContent.match(/(?\d+\.\d{2}) [A-Z]{3}/).groups; + + try { + assert.notEqual(Number(amount), Number(inp.value)); + } catch (e) { + console.error(e); + throw e; + } + } + } +} +``` + +# --before-all-- + +```js +async function delay(time) { + return new Promise((resolve) => setTimeout(resolve, time)); +} + +function getInnerTextExcept(doc, removingSelector) { + const body = doc.cloneNode(true); + + const squareElements = body.querySelectorAll(removingSelector); + squareElements.forEach(element => { + element.parentNode.removeChild(element) + }); + + return body.innerText; +} +``` + +# --seed-- + +## --seed-contents-- + +```html + + + + + + Currency Converter + + + + + + + + +
+ + + + +``` + +```css + +``` + +```jsx +const { useState, useMemo } = React; + +export function CurrencyConverter() { + +} +``` + +# --solutions-- + +```html + + + + + + Currency Converter + + + + + + + + +
+ + + + +``` + +```css +body { + background-color: #0a0a23; + font-family: Lato, sans-serif; +} + +main { + background-color: #1b1b32; + padding: 20px; + border-radius: 8px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + text-align: center; + width: 400px; + margin: 50px auto; +} + +h1 { + margin-bottom: 16px; + color: #f5f6f7; +} + +label { + color: #f5f6f7; +} + +input, +select { + width: 100%; + display: block; + box-sizing: border-box; + padding: 8px; + margin-bottom: 12px; + margin-top: 4px; + border: 1px solid #99c9ff; + border-radius: 4px; +} + +.conversion-display { + font-size: 16px; + color: #f5f6f7; + margin-bottom: 10px; +} + +p { + font-size: 18px; + font-weight: bold; + color: #acd157; +} +``` + +```jsx +export function CurrencyConverter() { + const [amount, setAmount] = React.useState(1); + const [startCurrency, setStartCurrency] = React.useState("USD"); + const [endCurrency, setEndCurrency] = React.useState("EUR"); + + const exchangeRates = { + USD: 1, + EUR: 0.85, + GBP: 0.75, + JPY: 110 + } + + const convertedAmounts = React.useMemo(() => { + const converted = {}; + Object.keys(exchangeRates).forEach((curr) => { + converted[curr] = ((amount / exchangeRates[startCurrency]) * exchangeRates[curr]).toFixed(2); + }); + return converted; + }, [amount, startCurrency]); + + return ( +
+

Currency Converter

+

{startCurrency} to {endCurrency} Conversion

+ setAmount(Number(e.target.value))} + /> + + +

Converted Amount: {convertedAmounts[endCurrency]} {endCurrency}

+
+ ); +} +``` diff --git a/curriculum/superblock-structure/full-stack.json b/curriculum/superblock-structure/full-stack.json index 9036a9f3b6b..0ef7e341e73 100644 --- a/curriculum/superblock-structure/full-stack.json +++ b/curriculum/superblock-structure/full-stack.json @@ -593,6 +593,9 @@ { "dashedName": "review-react-state-and-hooks" }, { "dashedName": "quiz-react-state-and-hooks" }, { "dashedName": "lecture-working-with-forms-in-react" }, + { + "dashedName": "lab-currency-converter" + }, { "dashedName": "lecture-working-with-data-fetching-and-memoization-in-react" },