mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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"
|
||||
}
|
||||
+491
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user