feat(curriculum): add lab-currency-converter (#59518)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-04-16 16:53:14 +02:00
committed by GitHub
parent 1dfb290cab
commit 2541f93fb9
5 changed files with 528 additions and 1 deletions
+7 -1
View File
@@ -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": [
@@ -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.
@@ -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"
}
@@ -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(/(?<amount>\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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Currency Converter</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
<script
data-plugins="transform-modules-umd"
type="text/babel"
src="index.jsx"
></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script
data-plugins="transform-modules-umd"
type="text/babel"
data-presets="react"
data-type="module"
>
import { CurrencyConverter } from './index.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(<CurrencyConverter />);
</script>
</body>
</html>
```
```css
```
```jsx
const { useState, useMemo } = React;
export function CurrencyConverter() {
}
```
# --solutions--
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Currency Converter</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
<script
data-plugins="transform-modules-umd"
type="text/babel"
src="index.jsx"
></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script
data-plugins="transform-modules-umd"
type="text/babel"
data-presets="react"
data-type="module"
>
import { CurrencyConverter } from './index.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(<CurrencyConverter />);
</script>
</body>
</html>
```
```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 (
<main>
<h1>Currency Converter</h1>
<p className="conversion-display">{startCurrency} to {endCurrency} Conversion</p>
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
<label>
Start Currency:
<select value={startCurrency} onChange={(e) => setStartCurrency(e.target.value)}>
{Object.keys(exchangeRates).map((curr) => (
<option key={curr} value={curr}>
{curr}
</option>
))}
</select>
</label>
<label>
Target Currency:
<select value={endCurrency} onChange={(e) => setEndCurrency(e.target.value)}>
{Object.keys(exchangeRates).map((curr) => (
<option key={curr} value={curr}>
{curr}
</option>
))}
</select>
</label>
<p>Converted Amount: <span>{convertedAmounts[endCurrency]}</span> {endCurrency}</p>
</main>
);
}
```
@@ -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"
},