From cba60d8511a31405afc13e05caa041234447003e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 20 Apr 2026 10:33:28 -0700 Subject: [PATCH] feat: add flashcard quiz app lab (#62166) Co-authored-by: Kolade --- client/i18n/locales/english/intro.json | 6 + .../69b868127999e97f1903f8e1.md | 701 ++++++++++++++++++ .../blocks/lab-flashcard-quiz-app.json | 11 + .../front-end-development-libraries-v9.json | 1 + 4 files changed, 719 insertions(+) create mode 100644 curriculum/challenges/english/blocks/lab-flashcard-quiz-app/69b868127999e97f1903f8e1.md create mode 100644 curriculum/structure/blocks/lab-flashcard-quiz-app.json diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 4eae230e659..8487155bd94 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -6248,6 +6248,12 @@ "In this workshop, you will continue to practice working with TypeScript by building a fortune telling app." ] }, + "lab-flashcard-quiz-app": { + "title": "Build a Flashcard Quiz App", + "intro": [ + "In this lab, you will practice using TypeScript by building a flashcard quiz app." + ] + }, "review-typescript": { "title": "Typescript Review", "intro": [ diff --git a/curriculum/challenges/english/blocks/lab-flashcard-quiz-app/69b868127999e97f1903f8e1.md b/curriculum/challenges/english/blocks/lab-flashcard-quiz-app/69b868127999e97f1903f8e1.md new file mode 100644 index 00000000000..e00a201ec81 --- /dev/null +++ b/curriculum/challenges/english/blocks/lab-flashcard-quiz-app/69b868127999e97f1903f8e1.md @@ -0,0 +1,701 @@ +--- +id: 69b868127999e97f1903f8e1 +title: Build a Flashcard Quiz App +challengeType: 25 +dashedName: lab-flashcard-quiz-app +--- + +# --description-- + +In this lab, you will create an app that displays and stores flashcard data that can +be retrieved later. + +**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab. + +**User Stories:** + +1) You should have an HTML element with an id of `flashcard`. +2) You should have an interface called `FlashCard`. +3) The `FlashCard` interface should contain the following properties `questionText` and `questionAnswer` both of type `string`. +4) You should have a collection of `FlashCard` elements called `currentCards`. +5) When the `#flashcard` element is clicked, the card should have the `flipped` class. +6) You should have an element with an id of `delete-btn`. +7) When the `#delete-btn` element is clicked, it should remove current card and display the previous card data. +8) You should create an entry form with an id of `entry-form` to be able to add more flashcards to the `currentCards` collection on submit. +9) The two elements inside of the form should have an id of `front-text` and `back-text` respectively. +10) You should create and call an `InvalidUserInputError` when either the question text or question answer is empty in the entry form. + +# --hints-- + +You should have an HTML element with an id of `flashcard`. + +```js +const element = document.querySelector("#flashcard"); +assert.exists(element); +``` + +You should have a `FlashCard` interface. + +```js +const explorer = await __helpers.Explorer(code); +console.log(explorer.interfaces.FlashCard) +assert.exists(explorer.interfaces.FlashCard); +``` + +The `FlashCard` interface should have a `questionText` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +const { FlashCard } = explorer.interfaces; +assert.isTrue(FlashCard.hasTypeProps([{ name: "questionText", type: "string" }])); +``` + +The `FlashCard` interface should have a `questionAnswer` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +const { FlashCard } = explorer.interfaces; +assert.isTrue(FlashCard.hasTypeProps([{ name: "questionAnswer", type: "string" }])); +``` + +You should have a collection of `FlashCard` elements called `currentCards` with the type `FlashCard[]`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.variables.currentCards); +assert.isTrue(explorer.variables.currentCards.annotation.matches('FlashCard[]')); +``` + +When the `#flashcard` element is first clicked, the element should receive the `flipped` class. + +```js +const element = document.querySelector("#flashcard"); +element.click(); +assert.isTrue(element.classList.contains("flipped")); +``` + +You should have an element with an id of `delete-btn`. + +```js +const element = document.querySelector("#flashcard"); +assert.exists(element); +``` + +When the `delete-btn` is clicked, a flashcard element should be removed from the `currentCards` collection. + +```js +const entryForm = document.querySelector("#entry-form"); +const frontText = document.querySelector("#front-text"); +const backText = document.querySelector("#back-text"); +const deleteBtn = document.querySelector("#delete-btn"); +const flashCard = document.querySelector("#flashcard"); +const submitBtn = entryForm.querySelector("button[type='submit']"); + +frontText.value = "Test question"; +backText.value = "Test answer"; +submitBtn.click(); + +frontText.value = "Test question 2"; +backText.value = "Test answer 2"; +submitBtn.click(); + +deleteBtn.click(); +assert.notInclude(flashCard.textContent, "Test question 2"); +``` + +When the `delete-btn` is clicked, the previous flashcard data should be displayed. + +```js +const entryForm = document.querySelector("#entry-form"); +const frontText = document.querySelector("#front-text"); +const backText = document.querySelector("#back-text"); +const deleteBtn = document.querySelector("#delete-btn"); +const flashCard = document.querySelector("#flashcard"); +const submitBtn = entryForm.querySelector("button[type='submit']"); + +frontText.value = "Test Front 1"; +backText.value = "Test Back 1"; +submitBtn.click(); + +frontText.value = "Test Front 2"; +backText.value = "Test Back 2"; +submitBtn.click(); + +deleteBtn.click(); +assert.include(flashCard.textContent, "Test Front 1"); +assert.notInclude(flashCard.textContent, "Test Front 2"); +``` + +You should create an entry form with an id of `entry-form`. + +```js +const element = document.querySelector("#entry-form"); +assert.exists(element); +``` + +You should have two `textarea` elements of ids `front-text` and `back-text` respectively inside the form. + +```js +const entryForm = document.querySelector("#entry-form"); +const frontText = entryForm.querySelector("#front-text"); +const backText = entryForm.querySelector("#back-text"); + +assert.exists(frontText); +assert.exists(backText); +assert.equal(frontText.tagName.toLowerCase(), "textarea"); +assert.equal(backText.tagName.toLowerCase(), "textarea"); +``` + +An `InvalidUserInputError` should be thrown when either the question text or question answer is empty in the entry form. + +```js +const entryForm = document.querySelector("#entry-form"); +const frontText = document.querySelector("#front-text"); +const backText = document.querySelector("#back-text"); +const submitBtn = entryForm.querySelector("button[type='submit']"); + +const lengthBefore = currentCards.length; + +frontText.value = ""; +backText.value = "Some answer"; +submitBtn.click(); +assert.strictEqual(currentCards.length, lengthBefore); + +frontText.value = "Some question"; +backText.value = ""; +submitBtn.click(); +assert.strictEqual(currentCards.length, lengthBefore); +``` + +# --seed-- + +## --seed-contents-- + +```html + +``` + +```css + +``` + +```ts + +``` + +# --solutions-- + +```html + + + + + + Flash Card Quiz App + + + + + + +
+
+
+

Flash Card Quiz App

+
+
+
+
+
+
+
+
+
+
+ +
+

Manage Cards

+
+ +
+ +
+ +

All Cards

+
+ +
+ +

Add a New Card

+
+ + + + + + +

+ +
+ +
+
+
+
+ + + + +``` + +```css +:root { + --bg-color: #1a1d24; + --surface-color: #2c313a; + --primary-color: #00aaff; + --primary-hover-color: #0088cc; + --text-color: #e0e0e0; + --text-secondary-color: #a0a0a0; + --border-color: #444952; + --error-color: #ff4d4d; + --shadow-color: rgba(37, 33, 33, 0.2); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background-color: var(--bg-color); + color: var(--text-color); + font-family: 'Inter', system-ui, sans-serif; + + place-items: center; + min-height: 100vh; + padding: 1rem; +} + +h1 { + text-align: center; + font-weight: 700; + color: white; + margin-bottom: 2rem; + font-size: 2.5rem; +} + +.app-container { + display: flex; + gap: 2rem; + width: 100%; + max-width: 1200px; +} + +.flashcard-panel { + flex: 2; +} + +.controls-panel { + flex: 1; + background-color: var(--surface-color); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 10px 30px var(--shadow-color); + border: 1px solid var(--border-color); +} + +hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 2rem 0; +} + +#flashcard-body { + background-color: var(--surface-color); + border-radius: 20px; + padding: 2rem 2.5rem; + height: 500px; + box-shadow: 0 10px 30px var(--shadow-color); + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.flashcard-container { + display: flex; + flex-direction: column; + margin-bottom: 1.5rem; + perspective: 1000px; +} + +.flashcard { + height: 250px; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + font-size: 2rem; + transition: transform 0.2s ease-in-out; + margin-bottom: 0; + position: relative; + transform-style: preserve-3d; + transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1); +} + +.flashcard.flipped { + transform: rotateY(180deg); +} + +.card-inner { + position: relative; + width: 100%; + height: 100%; + transform-style: preserve-3d; +} + +.card-front, +.card-back { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.card-front { + transform: rotateY(0deg); +} + +.card-back { + transform: rotateY(180deg); +} + +.flashcard:hover { + transform: translateY(-5px); +} + +.button-group { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.card-actions { + display: flex; + gap: 1rem; +} + +#cards-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; +} + +.card { + color: var(--text-color); +} +.selected { + background: var(--primary-hover-color); +} + +button { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 8px; + padding: 12px 24px; + font-family: 'Inter', sans-serif; + font-weight: 500; + font-size: 1rem; + cursor: pointer; + transition: + background-color 0.2s ease, + transform 0.1s ease; + width: 100%; +} + +button:hover { + background-color: var(--primary-hover-color); +} + +button:active { + transform: scale(0.98); +} + +button.delete-bin { + background-color: #4a4e57; +} + +button.delete-btn:hover { + background-color: #60656f; +} + +.entry-form { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; + margin-bottom: 1.5rem; +} + +.entry-form label { + font-size: 0.9rem; + color: var(--text-secondary-color); + font-weight: 500; +} + +.entry-form textarea { + width: 100%; + padding: 12px; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-color); + font-family: 'Inter', sans-serif; + font-size: 1rem; + resize: vertical; + box-sizing: border-box; + min-height: 80px; +} + +.entry-form textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 170, 255, 0.3); +} + +.error { + color: var(--error-color); + text-align: center; + min-height: 1.2em; +} + +@media (max-width: 992px) { + .app-container { + flex-direction: column; + align-items: center; + gap: 1.5rem; + } + + .flashcard-panel, + .controls-panel { + width: 100%; + max-width: 700px; + } + + #flashcard-body { + height: auto; + min-height: 400px; + } +} + +@media (max-width: 480px) { + body { + padding: 0.5rem; + } + + .controls-panel, + #flashcard-body { + padding: 1.5rem; + } + + .flashcard { + height: 200px; + font-size: 1.5rem; + } + + button { + padding: 14px 20px; + } +} +``` + +```ts +const cardDisplay = document.querySelector('#current-card')!; +const cardButtonsContainer = + document.querySelector('#cards-list')!; +const frontInput = document.querySelector('#front-text')!; +const backInput = document.querySelector('#back-text')!; +const errorElement = + document.querySelector('#entry-error')!; +let currentCardIndex = -1; +let currentCards: FlashCard[] = []; + +interface FlashCard { + questionText: string; + questionAnswer: string; +} + +class InvalidUserInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidUserInputError'; + } +} + +const isButtonElement = (element: unknown): element is HTMLButtonElement => { + return element instanceof HTMLButtonElement; +}; + +function refresh(): void { + if (currentCards.length === 0 || currentCardIndex < 0) { + cardDisplay.querySelector('.card-front')!.textContent = ''; + cardDisplay.querySelector('.card-back')!.textContent = ''; + return; + } + + const card = currentCards[currentCardIndex]; + + cardDisplay.querySelector('.card-front')!.textContent = + card.questionText; + + cardDisplay.querySelector('.card-back')!.textContent = + card.questionAnswer; + + Array.from(cardButtonsContainer.children).forEach((child, i) => { + if (i === currentCardIndex) { + child.classList.add('selected'); + } else { + child.classList.remove('selected'); + } + }); +} + +function deleteCard(): void { + if (currentCardIndex < 0 || currentCards.length === 0) return; + + currentCards.splice(currentCardIndex, 1); + + const btnToRemove = cardButtonsContainer.children[currentCardIndex]; + if (btnToRemove) { + cardButtonsContainer.removeChild(btnToRemove); + } + + if (currentCards.length === 0) { + currentCardIndex = -1; + refresh(); + return; + } + + currentCardIndex = Math.max(0, currentCardIndex - 1); + + Array.from(cardButtonsContainer.children).forEach((child, i) => { + if (!isButtonElement(child)) { + console.warn(`Element {${child}} is not a button.`); + return; + } + + (child as HTMLButtonElement).onclick = () => { + currentCardIndex = i; + refresh(); + }; + }); + + refresh(); +} + +function createCardButton( + questionText: string, + index: number +): HTMLButtonElement { + const btn = document.createElement('button'); + btn.innerText = + questionText.length > 20 ? questionText.slice(0, 20) + '...' : questionText; + (btn as HTMLButtonElement).onclick = () => { + currentCardIndex = index; + refresh(); + }; + return btn; +} + +function uploadNewCard(): void { + try { + const questionText = frontInput.value.trim(); + const questionAnswer = backInput.value.trim(); + if (!questionText) + throw new InvalidUserInputError('Front text cannot be empty.'); + if (!questionAnswer) + throw new InvalidUserInputError('Back text cannot be empty.'); + const newCard: FlashCard = { questionText, questionAnswer }; + currentCards.push(newCard); + const newIndex = currentCards.length - 1; + const cardBtn = createCardButton(questionText, newIndex); + cardButtonsContainer.appendChild(cardBtn); + + currentCardIndex = newIndex; + refresh(); + frontInput.value = ''; + backInput.value = ''; + } catch (ex) { + if (ex instanceof InvalidUserInputError) { + errorElement.innerHTML = '\u26A0 ' + ex.message; + } else { + console.error('An unexpected error occurred:', ex); + } + } +} + +class FlashCardController { + private elements: { + flashcard: HTMLElement; + entryForm: HTMLFormElement; + deleteBtn: HTMLButtonElement; + } = {} as { + flashcard: HTMLElement; + entryForm: HTMLFormElement; + deleteBtn: HTMLButtonElement; + }; + + constructor() { + this.elements = { + flashcard: document.querySelector('.flashcard')!, + entryForm: document.querySelector('.entry-form')!, + deleteBtn: document.querySelector('#delete-btn')! + }; + this.initializeEventListeners(); + } + private initializeEventListeners(): void { + this.elements.flashcard.addEventListener('click', () => this.flipCard()); + + this.elements.entryForm.addEventListener('submit', (ev: SubmitEvent) => { + ev.preventDefault(); + uploadNewCard(); + }); + + this.elements.deleteBtn.addEventListener('click', () => deleteCard()); + } + + private flipCard(): void { + this.elements.flashcard.classList.toggle('flipped'); + } +} + +document.addEventListener('DOMContentLoaded', (event: Event) => { + new FlashCardController(); + frontInput.value = 'What is the capital of France?'; + backInput.value = 'Paris'; + uploadNewCard(); +}); +``` diff --git a/curriculum/structure/blocks/lab-flashcard-quiz-app.json b/curriculum/structure/blocks/lab-flashcard-quiz-app.json new file mode 100644 index 00000000000..042d49db24f --- /dev/null +++ b/curriculum/structure/blocks/lab-flashcard-quiz-app.json @@ -0,0 +1,11 @@ +{ + "isUpcomingChange": true, + "dashedName": "lab-flashcard-quiz-app", + "helpCategory": "JavaScript", + "blockLayout": "link", + "challengeOrder": [ + { "id": "69b868127999e97f1903f8e1", "title": "Build a Flashcard Quiz App" } + ], + "blockLabel": "lab", + "usesMultifileEditor": true +} diff --git a/curriculum/structure/superblocks/front-end-development-libraries-v9.json b/curriculum/structure/superblocks/front-end-development-libraries-v9.json index d8d45a2b594..df668e14596 100644 --- a/curriculum/structure/superblocks/front-end-development-libraries-v9.json +++ b/curriculum/structure/superblocks/front-end-development-libraries-v9.json @@ -101,6 +101,7 @@ "lab-product-showcase", "lecture-working-with-typescript-configuration-files", "workshop-fortune-teller-app", + "lab-flashcard-quiz-app", "review-typescript", "quiz-typescript" ]