From e9d61c29bb7215bfdd5f939749c3da93095b1b87 Mon Sep 17 00:00:00 2001 From: mrbubbles-src Date: Mon, 9 Feb 2026 09:07:43 +0100 Subject: [PATCH 1/7] Update dependencies and version numbers in package.json and bun.lock - Bump version of `shadcn` to 3.8.4. - Update `@ianvs/prettier-plugin-sort-imports` to 4.7.1. - Upgrade `@tailwindcss/postcss` to 4.1.18. - Update `@types/node` to 20.19.33, `@types/react` to 19.2.13, and `@types/react-dom` to 19.2.3. - Upgrade `eslint` to 9.39.2 and `typescript` to 5.9.3. - Update `express` and `express-rate-limit` versions in bun.lock for improved functionality and security. --- bun.lock | 14 ++++++++------ package.json | 18 +++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index dfe1b22..6e1ef4c 100644 --- a/bun.lock +++ b/bun.lock @@ -241,7 +241,7 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.0", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-soa2bPUJAFruLL4z/CnMfSEKGznm5ebz29fIa9PxYtu8HHyLKNE1NXAs6dylfw1jn/ilEIfO2oLLN6uAafb7DA=="], + "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.1", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4 || ^0.1.0", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], @@ -317,7 +317,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.40.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ=="], @@ -489,9 +489,9 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@20.19.31", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A=="], + "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], - "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -855,7 +855,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -991,6 +991,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -1399,7 +1401,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@3.8.2", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.17.2", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-4iqRkfmDmChg4lC7qBImi0KWrCKJbb0rNSs8QuocHmKAlX9U3s0ZcYUbth+2sv1ueferzfjD2hswCOm9Ng4ceA=="], + "shadcn": ["shadcn@3.8.4", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-pSad/m1+PGzB0aLsRBV0EkyGg9al1nJqYUuucg6d8v8xZspPZ5/ehGNEp5M4b1KQYqdO5/gGPbkhVbgmXqG9Pw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], diff --git a/package.json b/package.json index 3c418a5..dcb90ba 100644 --- a/package.json +++ b/package.json @@ -23,31 +23,31 @@ "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", - "shadcn": "^3.8.2", + "shadcn": "^3.8.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@tailwindcss/postcss": "^4", + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "^20.19.33", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9", + "eslint": "^9.39.2", "eslint-config-next": "16.1.6", "jsdom": "^28.0.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^4", - "typescript": "^5", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", "vitest": "^4.0.18" }, "ignoreScripts": [ From 0c6ee93a984f33a87e76867cdfe8279d109f9a6b Mon Sep 17 00:00:00 2001 From: mrbubbles-src Date: Mon, 9 Feb 2026 16:26:06 +0100 Subject: [PATCH 2/7] Adjust questions list card behavior --- components/students/student-table.tsx | 28 ++++++++++++--------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/components/students/student-table.tsx b/components/students/student-table.tsx index 0c0f6f8..ac9d1cd 100644 --- a/components/students/student-table.tsx +++ b/components/students/student-table.tsx @@ -186,7 +186,9 @@ export default function StudentTable({
- Student List + + Student List + {students.length ? `${activeCount} active · ${ @@ -208,12 +210,12 @@ export default function StudentTable({ This will permanently remove every student from the roster. - - Cancel - - Delete All - - + + Cancel + + Delete All + + ) : null} @@ -295,9 +297,7 @@ export default function StudentTable({ Cancel - handleDeleteStudent(student.id) - }> + onClick={() => handleDeleteStudent(student.id)}> Delete @@ -316,9 +316,7 @@ export default function StudentTable({ Student - - Status - + Status Actions @@ -354,9 +352,7 @@ export default function StudentTable({
- + From 3959ea2ba76c1cb75bea558e3c2abf65ff2e2d6f Mon Sep 17 00:00:00 2001 From: Manuel Fahrenholz Date: Mon, 9 Feb 2026 17:44:34 +0100 Subject: [PATCH 3/7] Refactor: unified quiz editor with bulk JSON import (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Refactors the quiz editor flow by unifying quiz details, question editing, and JSON import into a single **QuizEditorForm**, and adds bulk JSON import plus UX improvements. ## What changed - **QuizEditorForm** - Single “Quiz Builder” card now holds title, description, questions, and **Import from JSON** in one place. - **Bulk import**: JSON can be a single quiz object or an array of quizzes; multiple quizzes are merged into the current draft with clear feedback. - Validation and error handling for JSON import improved (parse errors, invalid shape) with toast and inline messages. - Optional **description** field for quizzes added; state and app reducer updated accordingly. - Height tracking on the builder card for more consistent layout. - **QuizImportCard removed** - Its behavior is folded into `QuizEditorForm`; no separate import step. - **App store / reducer** - New actions and state for quiz description; tests added in `context/__tests__/app-reducer.test.ts`. - **Other** - `quiz-editor-skeleton` and loading behavior updated for better loading feedback. - Docs: `components.md` and `routes.md` updated for the new flow. - `lib/models`, `lib/type-guards`, and `lib/page-info` updated as needed for descriptions and quiz shape. ## Why - One place to edit quiz metadata and questions and to import JSON reduces context switching and simplifies the UI. - Bulk import from JSON arrays supports faster setup when migrating or creating many quizzes. - Clear validation and errors make import failures easier to fix. ## Tests - **QuizEditorForm** (`components/quizzes/__tests__/quiz-editor-form.test.tsx`): Renders unified builder, imports valid JSON (single and array), shows errors for invalid JSON, and verifies “Save Quiz” behavior. - **App reducer** (`context/__tests__/app-reducer.test.ts`): Covers student and quiz actions (including description); existing tests extended/added as needed. No additional E2E tests: changes are form and state logic covered by the above unit tests. ## Checklist - [x] Changes limited to quiz editor refactor and related state/docs (no unrelated features). - [x] Tests added/updated for new and touched behavior. - [x] Documentation updated where the flow or components changed. --- bun.lock | 18 +- components/loading/quiz-editor-skeleton.tsx | 26 +- .../__tests__/quiz-editor-form.test.tsx | 150 ++++ components/quizzes/quiz-editor-form.tsx | 705 ++++++++++++------ components/quizzes/quiz-editor.tsx | 4 +- components/quizzes/quiz-import-card.tsx | 158 ---- context/__tests__/app-reducer.test.ts | 37 + context/app-store.tsx | 63 +- documentation/project-docs/components.md | 5 +- documentation/project-docs/routes.md | 2 +- lib/models.ts | 1 + lib/page-info.tsx | 25 +- lib/type-guards.ts | 1 + 13 files changed, 784 insertions(+), 411 deletions(-) create mode 100644 components/quizzes/__tests__/quiz-editor-form.test.tsx delete mode 100644 components/quizzes/quiz-import-card.tsx diff --git a/bun.lock b/bun.lock index 6e1ef4c..3a868b9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,31 +13,31 @@ "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", - "shadcn": "^3.8.2", + "shadcn": "^3.8.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", }, "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@tailwindcss/postcss": "^4", + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "^20.19.33", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9", + "eslint": "^9.39.2", "eslint-config-next": "16.1.6", "jsdom": "^28.0.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^4", - "typescript": "^5", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", "vitest": "^4.0.18", }, }, diff --git a/components/loading/quiz-editor-skeleton.tsx b/components/loading/quiz-editor-skeleton.tsx index bbe54d7..d27f3e2 100644 --- a/components/loading/quiz-editor-skeleton.tsx +++ b/components/loading/quiz-editor-skeleton.tsx @@ -1,22 +1,32 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" -function PlaceholderCard({ titleWidth }: { titleWidth: string }) { +/** + * Renders one placeholder card matching the merged quiz builder form card. + */ +function BuilderPlaceholderCard() { return (
- + - + + + + + + + + ) @@ -28,12 +38,8 @@ function PlaceholderCard({ titleWidth }: { titleWidth: string }) { */ export default function QuizEditorSkeleton() { return ( -
-
- - - -
+
+
@@ -45,7 +51,7 @@ export default function QuizEditorSkeleton() { - {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 7 }).map((_, index) => ( ))} diff --git a/components/quizzes/__tests__/quiz-editor-form.test.tsx b/components/quizzes/__tests__/quiz-editor-form.test.tsx new file mode 100644 index 0000000..2902e24 --- /dev/null +++ b/components/quizzes/__tests__/quiz-editor-form.test.tsx @@ -0,0 +1,150 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner'; + +import { renderWithProvider } from '@/__tests__/test-utils'; +import QuizEditorForm from '@/components/quizzes/quiz-editor-form'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('QuizEditorForm', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + /** + * Renders the quiz editor form with a fresh quiz draft. + * + * @returns Testing-library utilities and mounted form content. + */ + const renderForm = () => + renderWithProvider(); + + it('renders a unified builder card with question and import controls', () => { + const { container } = renderForm(); + + expect(screen.getByText('Quiz Builder')).toBeInTheDocument(); + expect(screen.getByLabelText(/quiz title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/quiz description/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/add question/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/import quiz from json/i)).toBeInTheDocument(); + expect(container.querySelector('.overflow-y-auto')).not.toBeInTheDocument(); + }); + + it('imports a valid json file into the current draft', async () => { + const user = userEvent.setup(); + const { container } = renderForm(); + + const fileInput = screen.getByLabelText(/import quiz from json/i); + const file = new File( + [ + JSON.stringify({ + title: 'Math Quiz', + description: 'optional', + questions: [ + { prompt: 'What is 2 + 2?', answer: '4' }, + { prompt: 'What is 3 + 3?', answer: '6' }, + ], + }), + ], + 'quiz.json', + { type: 'application/json' }, + ); + + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByDisplayValue('Math Quiz')).toBeInTheDocument(); + expect(screen.getByDisplayValue('optional')).toBeInTheDocument(); + expect( + screen.getByText( + 'Loaded 2 questions into the draft. Click "Save Quiz" to keep it.', + ), + ).toBeInTheDocument(); + expect(screen.getAllByText('What is 2 + 2?').length).toBeGreaterThan(0); + expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument(); + }); + }); + + it('shows an error for invalid json files', async () => { + const user = userEvent.setup(); + renderForm(); + + const fileInput = screen.getByLabelText(/import quiz from json/i); + const file = new File(['{invalid json'], 'broken.json', { + type: 'application/json', + }); + + await user.upload(fileInput, file); + + await waitFor(() => { + expect( + screen.getByText('Invalid JSON file. Please check the format and try again.'), + ).toBeInTheDocument(); + }); + }); + + it('shows a schema error for missing required json fields', async () => { + const user = userEvent.setup(); + renderForm(); + + const fileInput = screen.getByLabelText(/import quiz from json/i); + const file = new File([JSON.stringify({ title: 'Missing questions' })], 'invalid-schema.json', { + type: 'application/json', + }); + + await user.upload(fileInput, file); + + await waitFor(() => { + expect( + screen.getByText( + 'Import must match quiz objects with title and questions[{prompt,answer}].', + ), + ).toBeInTheDocument(); + }); + }); + + it('imports multiple quizzes from a json array', async () => { + const user = userEvent.setup(); + renderForm(); + + const fileInput = screen.getByLabelText(/import quiz from json/i); + const file = new File( + [ + JSON.stringify([ + { + title: 'Quiz A', + description: 'Set A', + questions: [{ prompt: '1+1?', answer: '2' }], + }, + { + title: 'Quiz B', + description: 'Set B', + questions: [{ prompt: '2+2?', answer: '4' }], + }, + ]), + ], + 'bulk.json', + { type: 'application/json' }, + ); + + await user.upload(fileInput, file); + + await waitFor(() => { + expect( + vi.mocked(toast.success).mock.calls.some( + ([message]) => + message === + 'Saved 2 quizzes from file. The last imported quiz is selected.', + ), + ).toBe(true); + }); + }); +}); diff --git a/components/quizzes/quiz-editor-form.tsx b/components/quizzes/quiz-editor-form.tsx index 4cb7055..52c13f3 100644 --- a/components/quizzes/quiz-editor-form.tsx +++ b/components/quizzes/quiz-editor-form.tsx @@ -2,7 +2,7 @@ import type { Question, Quiz } from '@/lib/models'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'; import { toast } from 'sonner'; @@ -33,43 +33,213 @@ import { FieldDescription, FieldError, FieldLabel, + FieldSeparator, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { Textarea } from '@/components/ui/textarea'; import { useAppStore } from '@/context/app-store'; type QuizEditorFormProps = { quiz: Quiz | null; quizId: string | null; - importCard: React.ReactNode; +}; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonArray | JsonObject | JsonPrimitive; +type JsonArray = JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; +type ImportedQuestionDraft = { + prompt: string; + answer: string; +}; +type ImportedQuizDraft = { + title: string; + description?: string; + questions: ImportedQuestionDraft[]; +}; +type QuizImportResult = + | { status: 'success'; drafts: ImportedQuizDraft[] } + | { status: 'invalid-json' } + | { status: 'invalid-schema' }; +type InputChangeEvent = Parameters< + NonNullable['onChange']> +>[0]; + +/** + * Checks whether a JSON value is a plain object. + * + * @param value - Parsed JSON value to inspect. + * @returns `true` when the value can be safely accessed by object keys. + */ +const isJsonObject = (value: JsonValue): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Safely reads a string field from a JSON object. + * + * @param object - Parsed JSON object. + * @param key - Field name to read. + * @returns A string when the field exists and is a string, otherwise `null`. + */ +const getJsonStringField = (object: JsonObject, key: string): string | null => { + const value = object[key]; + return typeof value === 'string' ? value : null; +}; + +/** + * Extracts valid prompt/answer pairs from a JSON value. + * + * @param value - Raw `questions` value from parsed JSON. + * @returns Question drafts with trimmed prompt/answer values. + */ +const parseImportedQuestions = (value: JsonValue): ImportedQuestionDraft[] => { + if (!Array.isArray(value)) { + return []; + } + + const parsedQuestions: ImportedQuestionDraft[] = []; + for (const questionValue of value) { + if (!isJsonObject(questionValue)) continue; + const promptValue = getJsonStringField(questionValue, 'prompt'); + const answerValue = getJsonStringField(questionValue, 'answer'); + const prompt = promptValue?.trim() ?? ''; + const answer = answerValue?.trim() ?? ''; + if (!prompt || !answer) continue; + parsedQuestions.push({ prompt, answer }); + } + + return parsedQuestions; +}; + +/** + * Parses one quiz object from JSON into a normalized draft. + * + * @param value - Raw JSON value expected to match one quiz payload. + * @returns A normalized draft when valid, otherwise `null`. + */ +const parseImportedQuizDraft = (value: JsonValue): ImportedQuizDraft | null => { + if (!isJsonObject(value)) { + return null; + } + + const rawTitle = getJsonStringField(value, 'title'); + const descriptionValue = value.description; + if (descriptionValue !== undefined && typeof descriptionValue !== 'string') { + return null; + } + const questionsValue = value.questions; + if (!rawTitle || !questionsValue) { + return null; + } + + const title = rawTitle.trim(); + const description = + typeof descriptionValue === 'string' ? descriptionValue.trim() : ''; + if (!title) { + return null; + } + + const questions = parseImportedQuestions(questionsValue); + if (!questions.length) { + return null; + } + + return { + title, + ...(description ? { description } : {}), + questions, + }; +}; + +/** + * Validates raw import text against the quiz import schema. + * + * Expected shapes: + * 1) `{ "title": string, "description"?: string, "questions": [{ "prompt": string, "answer": string }] }` + * 2) An array of the same quiz object shape for bulk import. + * + * @param payload - UTF-8 text read from the uploaded JSON file. + * @returns A discriminated result describing success or validation failure. + */ +const parseQuizImportPayload = (payload: string): QuizImportResult => { + let parsed: JsonValue; + try { + parsed = JSON.parse(payload) as JsonValue; + } catch { + return { status: 'invalid-json' }; + } + + if (Array.isArray(parsed)) { + if (!parsed.length) { + return { status: 'invalid-schema' }; + } + const drafts: ImportedQuizDraft[] = []; + for (const entry of parsed) { + const parsedDraft = parseImportedQuizDraft(entry); + if (!parsedDraft) { + return { status: 'invalid-schema' }; + } + drafts.push(parsedDraft); + } + return { status: 'success', drafts }; + } + + const parsedDraft = parseImportedQuizDraft(parsed); + if (!parsedDraft) { + return { status: 'invalid-schema' }; + } + return { status: 'success', drafts: [parsedDraft] }; +}; + +/** + * Reads a selected file as UTF-8 text. + * Uses `File.text()` when available and falls back to `FileReader` for + * environments that do not implement the modern file API. + * + * @param file - Browser file object selected from the import control. + * @returns Promise resolving to the file contents as text. + */ +const readFileAsText = (file: File): Promise => { + if (typeof file.text === 'function') { + return file.text(); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result === 'string') { + resolve(result); + return; + } + reject(new Error('Unable to read file contents.')); + }; + reader.onerror = () => reject(new Error('Unable to read file contents.')); + reader.readAsText(file); + }); }; /** * Renders quiz editing controls for quiz metadata and question management. * Accepts an optional existing quiz and emits store actions for create/update/delete flows. */ -export default function QuizEditorForm({ - quiz, - quizId, - importCard, -}: QuizEditorFormProps) { +export default function QuizEditorForm({ quiz, quizId }: QuizEditorFormProps) { const { state, actions } = useAppStore(); + const builderCardRef = useRef(null); // Form state - initialized from props, reset via key pattern in parent const [title, setTitle] = useState(quiz?.title ?? ''); + const [description, setDescription] = useState(quiz?.description ?? ''); const [questions, setQuestions] = useState(quiz?.questions ?? []); const [prompt, setPrompt] = useState(''); const [answer, setAnswer] = useState(''); const [quizError, setQuizError] = useState(null); const [questionError, setQuestionError] = useState(null); + const [importError, setImportError] = useState(null); + const [importNotice, setImportNotice] = useState(null); + const [builderCardHeight, setBuilderCardHeight] = useState( + null, + ); const [editingQuestionId, setEditingQuestionId] = useState( null, ); @@ -80,6 +250,41 @@ export default function QuizEditorForm({ [questions, editingQuestionId], ); + /** + * Tracks builder card height so the questions card can scroll instead of + * growing taller than the builder panel. + */ + useEffect(() => { + const element = builderCardRef.current; + if (!element) { + return; + } + + const measureHeight = () => { + const nextHeight = Math.round(element.getBoundingClientRect().height); + setBuilderCardHeight(nextHeight); + }; + + measureHeight(); + const rafId = window.requestAnimationFrame(measureHeight); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(measureHeight); + observer.observe(element); + return () => { + window.cancelAnimationFrame(rafId); + observer.disconnect(); + }; + } + + window.addEventListener('resize', measureHeight); + + return () => { + window.cancelAnimationFrame(rafId); + window.removeEventListener('resize', measureHeight); + }; + }, []); + /** * Validates quiz metadata, saves changes, and confirms with a toast. */ @@ -95,10 +300,10 @@ export default function QuizEditorForm({ } if (quizId) { - actions.updateQuiz(quizId, trimmed, questions); + actions.updateQuiz(quizId, trimmed, questions, description); toast.success('Quiz updated.'); } else { - actions.createQuiz(trimmed, questions); + actions.createQuiz(trimmed, questions, description); toast.success('Quiz created.'); } setQuizError(null); @@ -139,9 +344,8 @@ export default function QuizEditorForm({ setAnswer(''); setEditingQuestionId(null); setQuestionError(null); - toast.success( - editingQuestionId ? 'Question updated.' : 'Question added.', - ); + setImportNotice(null); + toast.success(editingQuestionId ? 'Question updated.' : 'Question added.'); }; /** @@ -201,176 +405,304 @@ export default function QuizEditorForm({ toast.success('Quiz deleted.'); }; + /** + * Imports a quiz JSON file into the current draft title and question list. + * + * @param event - File input change event carrying the selected `.json` file. + * @returns A promise that resolves after file validation and draft updates. + */ + const handleQuizFileImport = async (event: InputChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const payload = await readFileAsText(file); + const result = parseQuizImportPayload(payload); + if (result.status === 'invalid-json') { + const message = + 'Invalid JSON file. Please check the format and try again.'; + setImportError(message); + setImportNotice(null); + toast.error(message); + return; + } + if (result.status === 'invalid-schema') { + const message = + 'Import must match quiz objects with title and questions[{prompt,answer}].'; + setImportError(message); + setImportNotice(null); + toast.error(message); + return; + } + if (result.drafts.length > 1) { + for (const draft of result.drafts) { + const nextQuestions: Question[] = draft.questions.map((question) => ({ + id: crypto.randomUUID(), + prompt: question.prompt, + answer: question.answer, + })); + actions.createQuiz(draft.title, nextQuestions, draft.description); + } + const message = `Saved ${result.drafts.length} quizzes from file. The last imported quiz is selected.`; + setImportError(null); + setImportNotice(message); + toast.success(message); + return; + } + + const singleDraft = result.drafts[0]; + const importedQuestions: Question[] = singleDraft.questions.map( + (question) => ({ + id: crypto.randomUUID(), + prompt: question.prompt, + answer: question.answer, + }), + ); + + setTitle(singleDraft.title); + setDescription(singleDraft.description ?? ''); + setQuestions(importedQuestions); + setPrompt(''); + setAnswer(''); + setQuizError(null); + setQuestionError(null); + setEditingQuestionId(null); + setImportError(null); + const draftMessage = `Loaded ${importedQuestions.length} question${importedQuestions.length === 1 ? '' : 's'} into the draft. Click "Save Quiz" to keep it.`; + setImportNotice(draftMessage); + toast.success('Loaded quiz into draft. Not saved yet.'); + } catch (error) { + console.error('Failed to import quiz file', error); + const message = 'Could not read the file. Please try again.'; + setImportError(message); + setImportNotice(null); + toast.error(message); + } finally { + event.target.value = ''; + } + }; + return ( <> -
- -
+ +
+ + + Quiz Builder + + + Select a saved quiz, build new questions, or import JSON into the + current draft. + + + + - - Quiz Details - - Select an existing quiz or start a new one. - - - - - - - Quiz title - - - setTitle(event.target.value)} - className="text-base/relaxed h-9 placeholder:text-muted-foreground/70 placeholder:text-base/relaxed" - placeholder="e.g. Geography Review" - /> - - Titles are display-only and can be edited later. - - - - {quizError ? {quizError} : null} -
- + + + Quiz title + + + setTitle(event.target.value)} + className="h-9 text-base/relaxed placeholder:text-base/relaxed placeholder:text-muted-foreground/70" + placeholder="e.g. Geography Review" + /> + + Titles are display-only and can be edited later. + + + + + + Quiz description (optional) + + +