From 0b9e12f1685fd387eef1b0d81ab74ca04758d537 Mon Sep 17 00:00:00 2001 From: MmagdyhafezZ Date: Fri, 14 Nov 2025 21:52:42 -0700 Subject: [PATCH 1/3] feat: adding question control settings end to end --- .secrets.baseline | 42 +-- .tool-versions | 2 +- .../mock.jwt.cookie.auth.guard.ts | 9 +- .../migration.sql | 5 + apps/api/prisma/schema.prisma | 2 + .../dto/update.assignment.request.dto.ts | 18 ++ .../v1/services/assignment.service.ts | 4 +- .../v2/repositories/assignment.repository.ts | 6 +- .../v2/services/version-management.service.ts | 6 +- .../v2/tests/unit/__mocks__/ common-mocks.ts | 13 +- .../Header/CheckLearnerSideButton.tsx | 1 + .../app/author/(components)/Header/index.tsx | 16 + .../StepTwo/AssignmentQuestionControls.tsx | 136 +++++++++ .../app/author/[assignmentId]/config/page.tsx | 2 + apps/web/app/globals.css | 34 +++ .../(components)/AboutTheAssignment/index.tsx | 217 ++++++++++++- .../Question/QuestionContainer.tsx | 11 +- .../(components)/Question/TextQuestion.tsx | 9 +- .../learner/(components)/Question/index.tsx | 28 ++ .../learner/(components)/SecurityMonitor.tsx | 100 ++++++ .../__tests__/SecurityMonitor.test.tsx | 219 ++++++++++++++ .../questions/ClientComponent.tsx | 1 + .../questions/LearnerLayout.tsx | 5 +- .../[assignmentId]/successPage/Question.tsx | 19 +- apps/web/components/MarkDownEditor.tsx | 63 +++- apps/web/components/MarkdownViewer.tsx | 23 +- apps/web/config/constants.ts | 5 + apps/web/config/types.ts | 10 + apps/web/jest.config.js | 4 + apps/web/jest.setup.js | 28 ++ apps/web/stores/assignmentConfig.ts | 22 +- apps/web/stores/learner.ts | 2 +- package.json | 9 +- yarn.lock | 286 +++++++++++++++++- 34 files changed, 1280 insertions(+), 77 deletions(-) create mode 100644 apps/api/prisma/migrations/20251112211321_add_question_controls/migration.sql create mode 100644 apps/web/app/author/(components)/StepTwo/AssignmentQuestionControls.tsx create mode 100644 apps/web/app/learner/(components)/SecurityMonitor.tsx create mode 100644 apps/web/app/learner/(components)/__tests__/SecurityMonitor.test.tsx diff --git a/.secrets.baseline b/.secrets.baseline index 0a89c52e..ed54bde8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": null, "lines": null }, - "generated_at": "2025-10-30T07:06:07Z", + "generated_at": "2025-11-15T04:48:47Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -317,15 +317,6 @@ "verified_result": null } ], - "apps/api/src/middleware/__tests__/data-transform.middleware.test.ts": [ - { - "hashed_secret": "c43b74f82f891e351ea8d73c4cac9988f05fa49f", - "is_verified": false, - "line_number": 41, - "type": "Base64 High Entropy String", - "verified_result": null - } - ], "apps/web/.env.template": [ { "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", @@ -443,49 +434,49 @@ { "hashed_secret": "b7e41a1408b0de53b6a18b0383983df52151bffd", "is_verified": false, - "line_number": 60, + "line_number": 57, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "7f8a4c8efb7a9d741a4131e6382406d04920a55c", "is_verified": false, - "line_number": 75, + "line_number": 72, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "ef584f952dbcdb807b1f2a5b688a6b2ac7beaf76", "is_verified": false, - "line_number": 158, + "line_number": 155, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "fca9f0a000cc0d99a6b89eff22b280d43b3dc23a", "is_verified": false, - "line_number": 168, + "line_number": 164, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "ad5a4eb98aace66b683002aa7038bb800f8b0a65", "is_verified": false, - "line_number": 174, + "line_number": 170, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "28681ff8a70bc722645c0ebe5c21fbd8a2ee904a", "is_verified": false, - "line_number": 183, + "line_number": 179, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "dd65cdacb216bbeca512abfb1ecd15d1c53ac7bd", "is_verified": false, - "line_number": 431, + "line_number": 427, "type": "Base64 High Entropy String", "verified_result": null } @@ -564,21 +555,21 @@ { "hashed_secret": "e1166e6dd837019ab04f130ab34c425e04161645", "is_verified": false, - "line_number": 384, + "line_number": 391, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", "is_verified": false, - "line_number": 431, + "line_number": 438, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "29080f1c58f9859ddaa6aeda7d2c410c12e222dc", "is_verified": false, - "line_number": 463, + "line_number": 470, "type": "Secret Keyword", "verified_result": null } @@ -596,10 +587,19 @@ { "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 135, + "line_number": 138, "type": "Basic Auth Credentials", "verified_result": null } + ], + "scripts/validate-env.sh": [ + { + "hashed_secret": "8da52328e314a37358f9758f38d5bb0e8687b6ab", + "is_verified": false, + "line_number": 74, + "type": "Secret Keyword", + "verified_result": null + } ] }, "version": "0.13.1+ibm.64.dss", diff --git a/.tool-versions b/.tool-versions index 9bc5c5e5..e084f460 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 22.12.0 +nodejs 22.0.0 yarn 1.22.22 \ No newline at end of file diff --git a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts index ee1dc1e1..1ea27d34 100644 --- a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts +++ b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts @@ -22,15 +22,14 @@ export class MockJwtCookieAuthGuard extends AuthGuard("cookie-strategy") { const request: RequestWithUserSession = context.switchToHttp().getRequest(); request.user = { - userId: "dev-user", - role: UserRole.LEARNER, - groupId: "string", - assignmentId: 1888, + userId: "magdy.hafez@ibm.com", + role: UserRole.AUTHOR, + groupId: "autogen-faculty-v1-course-v1-IND-AI0103EN-v1", + assignmentId: 1, gradingCallbackRequired: false, returnUrl: "https://skills.network", launch_presentation_locale: "en", }; - return true; } } diff --git a/apps/api/prisma/migrations/20251112211321_add_question_controls/migration.sql b/apps/api/prisma/migrations/20251112211321_add_question_controls/migration.sql new file mode 100644 index 00000000..edce2085 --- /dev/null +++ b/apps/api/prisma/migrations/20251112211321_add_question_controls/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Assignment" ADD COLUMN "questionControls" JSONB; + +-- AlterTable +ALTER TABLE "AssignmentVersion" ADD COLUMN "questionControls" JSONB; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fab58ec1..084f2afd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -237,6 +237,7 @@ model Assignment { showSubmissionFeedback Boolean @default(true) /// Should the AI provide feedback when the learner submits a question showQuestions Boolean @default(true) /// Should the questions be shown to the learner correctAnswerVisibility CorrectAnswerVisibility @default(ALWAYS) /// When should correct answers be shown to learners + questionControls Json? /// Question-level controls (e.g., allowCopy, allowPaste, allowRightClick) updatedAt DateTime @default(now()) @updatedAt /// The DateTime at which the assignment was last updated languageCode String? /// The language code for the assignment currentVersionId Int? /// The ID of the current active version @@ -287,6 +288,7 @@ model AssignmentVersion { showSubmissionFeedback Boolean @default(true) /// Show submission feedback showQuestions Boolean @default(true) /// Show questions correctAnswerVisibility CorrectAnswerVisibility @default(ALWAYS) /// When to show correct answers to learners + questionControls Json? /// Question-level controls (e.g., allowCopy, allowPaste, allowRightClick) languageCode String? /// Language code createdBy String /// User who created this version createdAt DateTime @default(now()) /// When version was created diff --git a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts index b88cf963..8756a385 100644 --- a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts +++ b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts @@ -11,10 +11,19 @@ import { IsEnum, IsInt, IsNumber, + IsObject, IsOptional, IsString, } from "class-validator"; +export interface QuestionControls { + allowCopy?: boolean; + allowPaste?: boolean; + allowRightClick?: boolean; + preventPrint?: boolean; + [key: string]: boolean | undefined; +} + export class UpdateAssignmentRequestDto { @ApiProperty({ description: "The name of the assignment.", @@ -227,4 +236,13 @@ export class UpdateAssignmentRequestDto { }) @IsOptional() correctAnswerVisibility: CorrectAnswerVisibility; + + @ApiProperty({ + description: "Question-level controls (copy, paste, right-click)", + required: false, + type: "object", + }) + @IsOptional() + @IsObject() + questionControls?: QuestionControls; } diff --git a/apps/api/src/api/assignment/v1/services/assignment.service.ts b/apps/api/src/api/assignment/v1/services/assignment.service.ts index 69e03405..b4280272 100644 --- a/apps/api/src/api/assignment/v1/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v1/services/assignment.service.ts @@ -304,7 +304,7 @@ export class AssignmentServiceV1 { }, }); - return authoredAssignments; + return authoredAssignments as AssignmentResponseDto[]; } const results = await this.prisma.assignmentGroup.findMany({ @@ -322,7 +322,7 @@ export class AssignmentServiceV1 { return results.map((result) => ({ ...result.assignment, - })); + })) as AssignmentResponseDto[]; } async replace( diff --git a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts index add21496..4e0eea8b 100644 --- a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts @@ -42,6 +42,8 @@ const FIELDS = [ "showQuestionScore", "showSubmissionFeedback", "showQuestions", + "correctAnswerVisibility", + "questionControls", "languageCode", ] as const; @@ -215,7 +217,7 @@ export class AssignmentRepository { }, }); - return authoredAssignments; + return authoredAssignments as AssignmentResponseDto[]; } const results = await this.prisma.assignmentGroup.findMany({ @@ -231,7 +233,7 @@ export class AssignmentRepository { return results.map((result) => ({ ...result.assignment, - })); + })) as AssignmentResponseDto[]; } /** diff --git a/apps/api/src/api/assignment/v2/services/version-management.service.ts b/apps/api/src/api/assignment/v2/services/version-management.service.ts index d77d368a..b68b6ffb 100644 --- a/apps/api/src/api/assignment/v2/services/version-management.service.ts +++ b/apps/api/src/api/assignment/v2/services/version-management.service.ts @@ -17,6 +17,7 @@ import { Question, } from "@prisma/client"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; +import { assign } from "nodemailer/lib/shared"; import { UserRole, UserSession, @@ -24,7 +25,6 @@ import { import { Logger } from "winston"; import { PrismaService } from "../../../../database/prisma.service"; import { QuestionDto } from "../../dto/update.questions.request.dto"; -import { assign } from "nodemailer/lib/shared"; export interface CreateVersionDto { versionNumber?: string; @@ -303,6 +303,7 @@ export class VersionManagementService { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, correctAnswerVisibility: assignment.correctAnswerVisibility, + questionControls: assignment.questionControls, languageCode: assignment.languageCode, createdBy: userSession.userId, isDraft: createVersionDto.isDraft ?? true, @@ -551,6 +552,7 @@ export class VersionManagementService { showSubmissionFeedback: version.showSubmissionFeedback, showQuestions: version.showQuestions, correctAnswerVisibility: version.correctAnswerVisibility, + questionControls: version.questionControls, languageCode: version.languageCode, questionVersions: questionVersionsWithVariants, }; @@ -1066,6 +1068,7 @@ export class VersionManagementService { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, correctAnswerVisibility: assignment.correctAnswerVisibility, + questionControls: assignment.questionControls, languageCode: assignment.languageCode, }, include: { _count: { select: { questionVersions: true } } }, @@ -1358,6 +1361,7 @@ export class VersionManagementService { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, correctAnswerVisibility: assignment.correctAnswerVisibility, + questionControls: assignment.questionControls, languageCode: assignment.languageCode, createdBy: userSession.userId, isDraft: true, diff --git a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts index a4146eb5..52f3f0bd 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts @@ -970,6 +970,13 @@ export const createMockAssignment = ( updatedAt: new Date(), languageCode: "en", currentVersionId: 1, + questionControls: { + allowCopy: true, + allowPaste: true, + allowRightClick: true, + preventPrint: false, + preventScreenshot: false, + }, ...overrides, }); @@ -990,7 +997,7 @@ export const createMockGetAssignmentResponseDto = ( questions, success: true, ...overrides, - }; + } as GetAssignmentResponseDto; }; /** @@ -1006,7 +1013,7 @@ export const createMockLearnerGetAssignmentResponseDto = ( questions: [] as Question[], success: true, ...overrides, - }; + } as LearnerGetAssignmentResponseDto; }; /** @@ -1020,7 +1027,7 @@ export const createMockAssignmentResponseDto = ( return { ...assignment, ...overrides, - }; + } as AssignmentResponseDto; }; /** diff --git a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx index c35f4dd5..cd36ce47 100644 --- a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx +++ b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx @@ -56,6 +56,7 @@ const CheckLearnerSideButton: FC = (props) => { assignmentConfigstate.numberOfQuestionsPerAttempt, name: authorState.name, id: assignmentId, + questionControls: assignmentConfigstate.questionControls, }; function handleJumpToLearnerSide( questions: QuestionAuthorStore[], diff --git a/apps/web/app/author/(components)/Header/index.tsx b/apps/web/app/author/(components)/Header/index.tsx index 5493e03a..6aa9002f 100644 --- a/apps/web/app/author/(components)/Header/index.tsx +++ b/apps/web/app/author/(components)/Header/index.tsx @@ -138,6 +138,7 @@ function AuthorHeader() { allotedTimeMinutes, updatedAt, numberOfQuestionsPerAttempt, + questionControls, ] = useAssignmentConfig((state) => [ state.numAttempts, state.retakeAttemptCoolDownMinutes, @@ -150,6 +151,7 @@ function AuthorHeader() { state.allotedTimeMinutes, state.updatedAt, state.numberOfQuestionsPerAttempt, + state.questionControls, ]); const [ showSubmissionFeedback, @@ -507,6 +509,11 @@ function AuthorHeader() { gradingCriteriaOverview: string; } & { [key: string]: string | null }; + if (process.env.NODE_ENV === "development") { + console.log("=== Publishing Assignment ==="); + console.log("questionControls from store:", questionControls); + } + const assignmentData: ReplaceAssignmentRequest = { ...encodedFields, numAttempts, @@ -527,12 +534,21 @@ function AuthorHeader() { showAssignmentScore, correctAnswerVisibility, numberOfQuestionsPerAttempt, + questionControls, questions: questionsAreDifferent ? processQuestions(clonedCurrentQuestions) : null, versionDescription: description, versionNumber: versionNumber, }; + + if (process.env.NODE_ENV === "development") { + console.log("assignmentData being sent:", assignmentData); + console.log( + "questionControls in payload:", + assignmentData.questionControls, + ); + } if (assignmentData.introduction === null) { toast.error( publishImmediately diff --git a/apps/web/app/author/(components)/StepTwo/AssignmentQuestionControls.tsx b/apps/web/app/author/(components)/StepTwo/AssignmentQuestionControls.tsx new file mode 100644 index 00000000..2b4425a7 --- /dev/null +++ b/apps/web/app/author/(components)/StepTwo/AssignmentQuestionControls.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { stepTwoSections } from "@/config/constants"; +import { useAssignmentConfig } from "@/stores/assignmentConfig"; +import { cn } from "@/lib/strings"; +import { type ComponentPropsWithoutRef, type FC } from "react"; +import SectionWithTitle from "../ReusableSections/SectionWithTitle"; + +type Props = ComponentPropsWithoutRef<"div">; + +const AssignmentQuestionControls: FC = () => { + const [questionControls, setQuestionControls] = useAssignmentConfig( + (state) => [state.questionControls, state.setQuestionControls], + ); + + const handleToggle = ( + key: "allowCopy" | "allowPaste" | "allowRightClick" | "preventPrint", + ) => { + const newValue = !questionControls?.[key]; + const newControls = { + ...questionControls, + [key]: newValue, + }; + + if (process.env.NODE_ENV === "development") { + console.log(`=== Toggle ${key} ===`); + console.log(`Old value:`, questionControls?.[key]); + console.log(`New value:`, newValue); + console.log(`Full questionControls:`, newControls); + } + + setQuestionControls(newControls); + }; + + return ( + +
+ handleToggle("allowCopy")} + /> + handleToggle("allowPaste")} + /> + handleToggle("allowRightClick")} + /> +
+ +
+

+ Security Controls +

+
+ handleToggle("preventPrint")} + variant="prevent" + /> +
+
+
+ ); +}; + +interface ControlToggleProps { + label: string; + description: string; + checked: boolean; + onChange: () => void; + variant?: "allow" | "prevent"; +} + +const ControlToggle: FC = ({ + label, + description, + checked, + onChange, + variant = "allow", +}) => { + const bgColor = + variant === "prevent" + ? checked + ? "bg-orange-600" + : "bg-gray-200" + : checked + ? "bg-violet-600" + : "bg-gray-200"; + + const ringColor = + variant === "prevent" ? "focus:ring-orange-600" : "focus:ring-violet-600"; + + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +}; + +export default AssignmentQuestionControls; diff --git a/apps/web/app/author/[assignmentId]/config/page.tsx b/apps/web/app/author/[assignmentId]/config/page.tsx index 147e92e0..61c285dd 100644 --- a/apps/web/app/author/[assignmentId]/config/page.tsx +++ b/apps/web/app/author/[assignmentId]/config/page.tsx @@ -7,6 +7,7 @@ import AssignmentFeedback from "@authorComponents/StepTwo/AssignmentFeedback"; import AssignmentTime from "@authorComponents/StepTwo/AssignmentTime"; import { FooterNavigation } from "@authorComponents/StepTwo/FooterNavigation"; import AssignmentQuestionDisplay from "../../(components)/StepTwo/AssignmentQuestionDisplay"; +import AssignmentQuestionControls from "../../(components)/StepTwo/AssignmentQuestionControls"; interface Props { params: { assignmentId: string }; @@ -29,6 +30,7 @@ function Component(props: Props) { + ); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a4d43fe4..2b6d08c7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -80,6 +80,40 @@ body .w-md-editor-text-pre > code, body .w-md-editor-text-input { @apply !text-base; } +/* Blur content when tab is not visible and preventScreenshot is on */ +body.assignment-blur { + filter: blur(10px); + pointer-events: none; +} +/* Strong blur when “protection” is active */ +.exam-blur { + filter: blur(18px); + pointer-events: none; + user-select: none; + transition: filter 0.15s ease-out; +} + +/* Optional: add a dark overlay on top of blurred content */ +.exam-blur-overlay::before { + content: ""; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + pointer-events: none; + z-index: 40; +} + +/* Existing from previous answer */ +@media print { + body.no-print * { + display: none !important; + } +} + +body.assignment-blur { + filter: blur(10px); + pointer-events: none; +} /* For the markdown editor to still show in preview mode (bug in the library) */ body .w-md-editor-content { diff --git a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx index f20eb419..2c24e703 100644 --- a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx +++ b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx @@ -10,7 +10,11 @@ import { LearnerAssignmentState, } from "@/config/types"; import { getSupportedLanguages } from "@/lib/talkToBackend"; -import { useLearnerOverviewStore, useLearnerStore } from "@/stores/learner"; +import { + useAssignmentDetails, + useLearnerOverviewStore, + useLearnerStore, +} from "@/stores/learner"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -32,7 +36,10 @@ interface AssignmentSectionProps { const AssignmentSection: FC = ({ title, content }) => { const [isCollapsed, setIsCollapsed] = useState(false); - + const assignmentDetails = useAssignmentDetails( + (state) => state.assignmentDetails, + ); + const questionControls = assignmentDetails?.questionControls; return (
@@ -58,7 +65,10 @@ const AssignmentSection: FC = ({ title, content }) => { : "max-h-none opacity-100" }`} > - + {content || `No ${title.toLowerCase()} provided.`}
@@ -429,6 +439,207 @@ const AboutTheAssignment: FC = ({ content={gradingCriteriaOverview} /> + {assignment.questionControls && ( +
+
+

+ Assignment Restrictions +

+
+
+

+ The following restrictions have been configured for this + assignment: +

+
+
+
+ {assignment.questionControls.allowCopy ? ( + + + + ) : ( + + + + )} +
+
+

+ {assignment.questionControls.allowCopy + ? "Copying Allowed" + : "Copying Disabled"} +

+

+ {assignment.questionControls.allowCopy + ? "You can copy text from questions and your answers" + : "You cannot copy text during this assignment"} +

+
+
+ +
+
+ {assignment.questionControls.allowPaste ? ( + + + + ) : ( + + + + )} +
+
+

+ {assignment.questionControls.allowPaste + ? "Pasting Allowed" + : "Pasting Disabled"} +

+

+ {assignment.questionControls.allowPaste + ? "You can paste content into answer fields" + : "You cannot paste content into answer fields"} +

+
+
+ +
+
+ {assignment.questionControls.allowRightClick ? ( + + + + ) : ( + + + + )} +
+
+

+ {assignment.questionControls.allowRightClick + ? "Right Click Allowed" + : "Right Click Disabled"} +

+

+ {assignment.questionControls.allowRightClick + ? "You can access the context menu" + : "Right-click context menu is disabled"} +

+
+
+ +
+
+ {assignment.questionControls.preventPrint ? ( + + + + ) : ( + + + + )} +
+
+

+ {assignment.questionControls.preventPrint + ? "Printing Blocked" + : "Printing Allowed"} +

+

+ {assignment.questionControls.preventPrint + ? "Print functionality is disabled for this assignment" + : "You can print the assignment"} +

+
+
+
+
+
+ )} +
state.assignmentDetails, + ); + const questionControls = assignmentDetails?.questionControls; const assignmentId = useLearnerOverviewStore((state) => state.assignmentId); const [activeQuestionNumber, setActiveQuestionNumber] = useLearnerStore( (state) => [state.activeQuestionNumber, state.setActiveQuestionNumber], @@ -274,6 +282,7 @@ function Component(props: Props) { {question.translations?.[userPreferedLanguage]?.translatedText ?? question.question} diff --git a/apps/web/app/learner/(components)/Question/TextQuestion.tsx b/apps/web/app/learner/(components)/Question/TextQuestion.tsx index 6cd661c9..04516240 100644 --- a/apps/web/app/learner/(components)/Question/TextQuestion.tsx +++ b/apps/web/app/learner/(components)/Question/TextQuestion.tsx @@ -1,5 +1,5 @@ import { QuestionStore } from "@/config/types"; -import { useLearnerStore } from "@/stores/learner"; +import { useLearnerStore, useAssignmentDetails } from "@/stores/learner"; import MarkdownEditor from "@components/MarkDownEditor"; interface Props { @@ -9,6 +9,10 @@ interface Props { function TextQuestion(props: Props) { const { question } = props; const [setTextResponse] = useLearnerStore((state) => [state.setTextResponse]); + const assignmentDetails = useAssignmentDetails( + (state) => state.assignmentDetails, + ); + const questionControls = assignmentDetails?.questionControls; const maxWords = question?.maxWords || null; const maxCharacters = question?.maxCharacters || null; @@ -20,6 +24,9 @@ function TextQuestion(props: Props) { placeholder="Type your answer here" maxWords={maxWords} maxCharacters={maxCharacters} + allowCopy={questionControls?.allowCopy ?? true} + allowPaste={questionControls?.allowPaste ?? true} + allowRightClick={questionControls?.allowRightClick ?? true} /> ); } diff --git a/apps/web/app/learner/(components)/Question/index.tsx b/apps/web/app/learner/(components)/Question/index.tsx index 674682bc..ab2e6537 100644 --- a/apps/web/app/learner/(components)/Question/index.tsx +++ b/apps/web/app/learner/(components)/Question/index.tsx @@ -16,6 +16,7 @@ import { useEffect, useState, type ComponentPropsWithoutRef } from "react"; import Overview from "./Overview"; import QuestionContainer from "./QuestionContainer"; import TipsView from "./TipsView"; +import SecurityMonitor from "../SecurityMonitor"; interface Props extends ComponentPropsWithoutRef<"div"> { attempt: AssignmentAttemptWithQuestions; @@ -46,14 +47,36 @@ function QuestionPage(props: Props) { useEffect(() => { const fetchAssignment = async () => { + if (process.env.NODE_ENV === "development") { + console.log("=== fetchAssignment called ==="); + console.log("assignmentId:", assignmentId); + } + const assignment = await getAssignment(assignmentId); + if (process.env.NODE_ENV === "development") { + console.log("=== getAssignment response ==="); + console.log("Full assignment:", assignment); + console.log("questionControls field:", assignment?.questionControls); + } + if (assignment) { if ( !assignmentDetails || assignmentDetails.id !== assignment.id || JSON.stringify(assignmentDetails) !== JSON.stringify(assignment) ) { + if (process.env.NODE_ENV === "development") { + console.log( + "=== Question/index.tsx: Setting Assignment Details ===", + ); + console.log("assignment from API:", assignment); + console.log( + "questionControls from API:", + assignment.questionControls, + ); + } + setAssignmentDetails({ id: assignment.id, name: assignment.name, @@ -69,6 +92,7 @@ function QuestionPage(props: Props) { published: assignment.published, questionOrder: assignment.questionOrder, updatedAt: assignment.updatedAt, + questionControls: assignment.questionControls, }); } } else { @@ -197,6 +221,10 @@ function QuestionPage(props: Props) {
)} + {process.env.NODE_ENV === "development" && ( +
+ )} +
); } diff --git a/apps/web/app/learner/(components)/SecurityMonitor.tsx b/apps/web/app/learner/(components)/SecurityMonitor.tsx new file mode 100644 index 00000000..b525b3cd --- /dev/null +++ b/apps/web/app/learner/(components)/SecurityMonitor.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { QuestionControls } from "@/config/types"; +import { useEffect, useState } from "react"; + +interface SecurityMonitorProps { + questionControls?: QuestionControls; +} + +const SecurityMonitor: React.FC = ({ + questionControls, +}) => { + const [showWarning, setShowWarning] = useState(false); + const [warningMessage, setWarningMessage] = useState(""); + + // Debug logging + if (process.env.NODE_ENV === "development") { + console.log("=== SecurityMonitor Debug ==="); + console.log("questionControls:", questionControls); + console.log("- preventPrint:", questionControls?.preventPrint); + } + + useEffect(() => { + if (!questionControls) { + if (process.env.NODE_ENV === "development") { + console.log("SecurityMonitor: No questionControls provided"); + } + return; + } + + if (process.env.NODE_ENV === "development") { + console.log("SecurityMonitor: Setting up event listeners"); + console.log("- preventPrint:", questionControls.preventPrint); + } + + const handleKeyDown = (e: KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const ctrlOrCmd = isMac ? e.metaKey : e.ctrlKey; + + // Prevent Print (Ctrl/Cmd + P) + if (questionControls.preventPrint && ctrlOrCmd && e.key === "p") { + e.preventDefault(); + e.stopPropagation(); + showWarningToast("Printing is disabled for this assignment"); + return false; + } + }; + + const handleBeforePrint = (e: Event) => { + if (questionControls.preventPrint) { + e.preventDefault(); + e.stopPropagation(); + showWarningToast("Printing is disabled for this assignment"); + } + }; + + // Register event listeners + document.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("beforeprint", handleBeforePrint); + + // Cleanup + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("beforeprint", handleBeforePrint); + }; + }, [questionControls]); + + const showWarningToast = (message: string) => { + setWarningMessage(message); + setShowWarning(true); + setTimeout(() => { + setShowWarning(false); + }, 3000); + }; + + if (!showWarning) return null; + + return ( +
+
+ + + +

{warningMessage}

+
+
+ ); +}; + +export default SecurityMonitor; diff --git a/apps/web/app/learner/(components)/__tests__/SecurityMonitor.test.tsx b/apps/web/app/learner/(components)/__tests__/SecurityMonitor.test.tsx new file mode 100644 index 00000000..2cf27295 --- /dev/null +++ b/apps/web/app/learner/(components)/__tests__/SecurityMonitor.test.tsx @@ -0,0 +1,219 @@ +/** + * @jest-environment jsdom + * + * Note: This test requires @testing-library/react to be installed. + * Run: yarn add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event + * See: TESTING_SETUP.md for full setup instructions + */ + +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import SecurityMonitor from "../SecurityMonitor"; +import { QuestionControls } from "@/config/types"; + +describe("SecurityMonitor", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe("Print Prevention", () => { + it("should prevent Ctrl+P when preventPrint is enabled", async () => { + const questionControls: QuestionControls = { + preventPrint: true, + }; + + render(); + + const keydownEvent = new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = jest.spyOn(keydownEvent, "preventDefault"); + const stopPropagationSpy = jest.spyOn(keydownEvent, "stopPropagation"); + + act(() => { + document.dispatchEvent(keydownEvent); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect( + screen.getByText("Printing is disabled for this assignment"), + ).toBeInTheDocument(); + }); + + it("should prevent Cmd+P on Mac when preventPrint is enabled", async () => { + const questionControls: QuestionControls = { + preventPrint: true, + }; + + // Mock Mac platform + Object.defineProperty(navigator, "platform", { + value: "MacIntel", + configurable: true, + }); + + render(); + + const keydownEvent = new KeyboardEvent("keydown", { + key: "p", + metaKey: true, + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = jest.spyOn(keydownEvent, "preventDefault"); + + act(() => { + document.dispatchEvent(keydownEvent); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it("should prevent beforeprint event when preventPrint is enabled", async () => { + const questionControls: QuestionControls = { + preventPrint: true, + }; + + render(); + + const beforePrintEvent = new Event("beforeprint", { + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = jest.spyOn(beforePrintEvent, "preventDefault"); + + act(() => { + window.dispatchEvent(beforePrintEvent); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it("should NOT prevent printing when preventPrint is false", async () => { + const questionControls: QuestionControls = { + preventPrint: false, + }; + + render(); + + const keydownEvent = new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = jest.spyOn(keydownEvent, "preventDefault"); + + act(() => { + document.dispatchEvent(keydownEvent); + }); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Event Listener Cleanup", () => { + it("should remove event listeners on unmount", async () => { + const questionControls: QuestionControls = { + preventPrint: true, + preventScreenshot: true, + }; + + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + const removeEventListenerSpy = jest.spyOn( + document, + "removeEventListener", + ); + const windowAddSpy = jest.spyOn(window, "addEventListener"); + const windowRemoveSpy = jest.spyOn(window, "removeEventListener"); + + const { unmount } = render( + , + ); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + true, + ); + expect(windowAddSpy).toHaveBeenCalledWith( + "beforeprint", + expect.any(Function), + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + true, + ); + expect(windowRemoveSpy).toHaveBeenCalledWith( + "beforeprint", + expect.any(Function), + ); + }); + }); + + describe("No Controls Enabled", () => { + it("should not render anything when no questionControls provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should not prevent any actions when all controls are false", async () => { + const questionControls: QuestionControls = { + preventPrint: false, + preventScreenshot: false, + }; + + render(); + + const printEvent = new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + const screenshotEvent = new KeyboardEvent("keydown", { + key: "PrintScreen", + bubbles: true, + cancelable: true, + }); + + const printPreventSpy = jest.spyOn(printEvent, "preventDefault"); + const screenshotPreventSpy = jest.spyOn( + screenshotEvent, + "preventDefault", + ); + + act(() => { + document.dispatchEvent(printEvent); + document.dispatchEvent(screenshotEvent); + }); + + expect(printPreventSpy).not.toHaveBeenCalled(); + expect(screenshotPreventSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/learner/[assignmentId]/questions/ClientComponent.tsx b/apps/web/app/learner/[assignmentId]/questions/ClientComponent.tsx index 1cab4d84..c08de9e7 100644 --- a/apps/web/app/learner/[assignmentId]/questions/ClientComponent.tsx +++ b/apps/web/app/learner/[assignmentId]/questions/ClientComponent.tsx @@ -57,6 +57,7 @@ const ClientLearnerLayout: React.FC = ({ showSubmissionFeedback: assignmentDetails.showSubmissionFeedback || false, showQuestionScore: assignmentDetails.showQuestionScore || false, showAssignmentScore: assignmentDetails.showAssignmentScore || false, + questionControls: assignmentDetails.questionControls, }); }, [assignmentDetails, setAssignmentDetails]); diff --git a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx index 2f507d63..25a69521 100644 --- a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx +++ b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx @@ -120,7 +120,10 @@ async function AttemptLoader({ return ( role === "learner" && ( -
+
= ({ currentIndex: 0, }); + const assignmentDetails = useAssignmentDetails( + (state) => state.assignmentDetails, + ); + const questionControls = assignmentDetails?.questionControls; const [octokit, setOctokit] = useState(null); const assignmentId = useLearnerOverviewStore((state) => state.assignmentId); const [token, setToken] = useState(null); @@ -517,7 +524,10 @@ const Question: FC = ({ : "bg-gray-50 border border-gray-300 rounded p-2" }`} > - + {learnerResponse.toString()}

@@ -801,7 +811,10 @@ const Question: FC = ({ )} - + {questionText} diff --git a/apps/web/components/MarkDownEditor.tsx b/apps/web/components/MarkDownEditor.tsx index 0d7da225..6c3264b9 100644 --- a/apps/web/components/MarkDownEditor.tsx +++ b/apps/web/components/MarkDownEditor.tsx @@ -21,6 +21,9 @@ interface Props extends ComponentPropsWithoutRef<"section"> { textareaClassName?: string; maxWords?: number | null; maxCharacters?: number | null; + allowCopy?: boolean; + allowPaste?: boolean; + allowRightClick?: boolean; } const MarkdownEditor: React.FC = ({ @@ -31,6 +34,9 @@ const MarkdownEditor: React.FC = ({ maxWords, maxCharacters, placeholder = "Write your question here...", + allowCopy = true, + allowPaste = true, + allowRightClick = true, }) => { const quillRef = useRef(null); const [quillInstance, setQuillInstance] = useState(null); @@ -39,6 +45,7 @@ const MarkdownEditor: React.FC = ({ ); const [charCount, setCharCount] = useState(value?.length ?? 0); + // Initialize Quill useEffect(() => { let isMounted = true; const initializeQuill = async () => { @@ -74,7 +81,6 @@ const MarkdownEditor: React.FC = ({ ["link", "image", "video"], ["clean"], ], - syntax: { highlight: (text: string) => hljs.highlightAuto(text).value, }, @@ -125,16 +131,60 @@ const MarkdownEditor: React.FC = ({ }; }, [quillInstance]); + // Prevent paste (strongest method — works 100%) + useEffect(() => { + if (!quillInstance) return; + + const handlePaste = (event: ClipboardEvent) => { + if (allowPaste) return; + + const active = document.activeElement; + if (!active) return; + + if ( + active === quillInstance.root || + quillInstance.root.contains(active) + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + // capture:true ensures we intercept BEFORE Quill + document.addEventListener("paste", handlePaste, true); + + return () => { + document.removeEventListener("paste", handlePaste, true); + }; + }, [quillInstance, allowPaste]); + + // Prevent right-click if needed + useEffect(() => { + if (!quillInstance || allowRightClick) return; + + const root = quillInstance.root; + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + root.addEventListener("contextmenu", handleContextMenu); + + return () => root.removeEventListener("contextmenu", handleContextMenu); + }, [quillInstance, allowRightClick]); + + // Sync value externally useEffect(() => { if (quillInstance) { const currentHTML = quillInstance.root.innerHTML; - if (currentHTML !== value && !quillInstance.hasFocus()) { quillInstance.root.innerHTML = value; } } }, [quillInstance, value]); + // Style injection useEffect(() => { const style = document.createElement("style"); style.innerHTML = ` @@ -150,9 +200,8 @@ const MarkdownEditor: React.FC = ({ background-color: transparent !important; height: auto !important; overflow: visible !important; - padding: 0 !important; + padding: 0 !important; } - /* Optional: Adjust spacing for list items, paragraphs, etc. */ .ql-editor p, .ql-editor li, .ql-editor blockquote { @@ -172,7 +221,6 @@ const MarkdownEditor: React.FC = ({ .ql-editor pre { background-color: #f5f5f5 !important; } - /* Syntax highlighting tweak */ .ql-editor .hljs { padding: 0.2em !important; font-size: 0.95em !important; @@ -180,10 +228,9 @@ const MarkdownEditor: React.FC = ({ `; document.head.appendChild(style); - return () => { - document.head.removeChild(style); - }; + return () => document.head.removeChild(style); }, []); + return (
; +interface Props extends ComponentPropsWithoutRef<"div"> { + allowCopy?: boolean; +} /** * MarkdownViewer @@ -28,9 +29,9 @@ type Props = ComponentPropsWithoutRef<"div">; * It uses the Quill editor without a toolbar and applies syntax highlighting via Highlight.js. */ const MarkdownViewer: FC = (props) => { - const { className, children, ...restOfProps } = props; + const { className, children, allowCopy = true, ...restOfProps } = props; const quillRef = useRef(null); - const [quillInstance, setQuillInstance] = useState(null); + const [quillInstance, setQuillInstance] = useState(null); useEffect(() => { if (quillInstance) { @@ -64,6 +65,7 @@ const MarkdownViewer: FC = (props) => { } }, [children]); + // Style injection (copy control + typography) useEffect(() => { const style = document.createElement("style"); style.innerHTML = ` @@ -71,10 +73,11 @@ const MarkdownViewer: FC = (props) => { border: none !important; min-height: auto !important; overflow: visible !important; - user-select: none !important; + ${allowCopy ? "" : "user-select: none !important;"} + } + .quill-viewer .ql-container .ql-editor .ql-code-block-container .ql-ui { + display: none !important; } - .quill-viewer .ql-container .ql-editor .ql-code-block-container .ql-ui{ - display: none !important;} .ql-container.ql-snow .ql-editor { font-family: "IBM Plex Sans", sans-serif !important; font-size: 16px !important; @@ -82,9 +85,8 @@ const MarkdownViewer: FC = (props) => { background-color: transparent !important; min-height: auto !important; overflow: visible !important; - padding: 0 !important; + padding: 0 !important; } - /* Optional: Adjust spacing for list items, paragraphs, etc. */ .ql-editor p, .ql-editor li, .ql-editor blockquote { @@ -104,7 +106,6 @@ const MarkdownViewer: FC = (props) => { .ql-editor pre { background-color: #f5f5f5 !important; } - /* Syntax highlighting tweak */ .ql-editor .hljs { padding: 0.2em !important; font-size: 0.95em !important; @@ -115,7 +116,7 @@ const MarkdownViewer: FC = (props) => { return () => { document.head.removeChild(style); }; - }, []); + }, [allowCopy]); return (
diff --git a/apps/web/config/constants.ts b/apps/web/config/constants.ts index 7183d2d3..cd4bd588 100644 --- a/apps/web/config/constants.ts +++ b/apps/web/config/constants.ts @@ -94,6 +94,11 @@ export const stepTwoSections = { title: "7. How should questions be presented to the learner?", required: true, }, + questionControls: { + title: "8. Question Controls", + description: "Configure what actions learners can perform", + required: false, + }, } as const; export const formatPricePerMillionTokens = (pricePerToken: number) => { diff --git a/apps/web/config/types.ts b/apps/web/config/types.ts index 33d257f2..9b001d46 100644 --- a/apps/web/config/types.ts +++ b/apps/web/config/types.ts @@ -591,6 +591,7 @@ export type ReplaceAssignmentRequest = { showQuestionScore?: boolean; showSubmissionFeedback?: boolean; correctAnswerVisibility?: CorrectAnswerVisibility; + questionControls?: QuestionControls; updatedAt: number; questionVariationNumber?: number; versionDescription?: string; @@ -643,6 +644,14 @@ export interface AssignmentAttemptWithQuestions extends AssignmentAttempt { preferredLanguage?: string; } +export interface QuestionControls { + allowCopy?: boolean; + allowPaste?: boolean; + allowRightClick?: boolean; + preventPrint?: boolean; + [key: string]: boolean | undefined; +} + export interface AssignmentDetails { allotedTimeMinutes?: number; numAttempts?: number; @@ -664,6 +673,7 @@ export interface AssignmentDetails { showSubmissionFeedback?: boolean; correctAnswerVisibility?: CorrectAnswerVisibility; numberOfQuestionsPerAttempt?: number; + questionControls?: QuestionControls; } export interface AssignmentDetailsLocal extends AssignmentDetails { diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index f66846df..52c22464 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -19,6 +19,10 @@ const customJestConfig = { coverageDirectory: "../coverage/web", coverageReporters: ["text", "lcov", "html"], moduleNameMapper: { + "^@/stores/(.*)$": "/stores/$1", + "^@/components/(.*)$": "/components/$1", + "^@/config/(.*)$": "/config/$1", + "^@/lib/(.*)$": "/lib/$1", "^@/(.*)$": "/app/$1", }, }; diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js index a92cb58e..9643751d 100644 --- a/apps/web/jest.setup.js +++ b/apps/web/jest.setup.js @@ -2,3 +2,31 @@ // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` // Setup for Node.js environment tests +import '@testing-library/jest-dom'; + +// Polyfill for ClipboardEvent (not available in jsdom) +if (typeof global.ClipboardEvent === 'undefined') { + global.ClipboardEvent = class ClipboardEvent extends Event { + constructor(type, eventInitDict) { + super(type, eventInitDict); + this.clipboardData = eventInitDict?.clipboardData || null; + } + }; +} + +// Mock matchMedia (not available in jsdom) +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} diff --git a/apps/web/stores/assignmentConfig.ts b/apps/web/stores/assignmentConfig.ts index 75aeffa1..44bacbb8 100644 --- a/apps/web/stores/assignmentConfig.ts +++ b/apps/web/stores/assignmentConfig.ts @@ -1,5 +1,9 @@ import { withUpdatedAt } from "./middlewares"; -import { GradingData, QuestionDisplayType } from "@/config/types"; +import { + GradingData, + QuestionDisplayType, + QuestionControls, +} from "@/config/types"; import { extractAssignmentId } from "@/lib/strings"; import { createJSONStorage, devtools, persist } from "zustand/middleware"; import { createWithEqualityFn } from "zustand/traditional"; @@ -26,6 +30,8 @@ type GradingDataActions = { setUpdatedAt: (updatedAt: number) => void; setAssignmentConfigStore: (state: Partial) => void; setStrictTimeLimit: (strictTimeLimit: boolean) => void; + questionControls?: QuestionControls; + setQuestionControls: (questionControls: QuestionControls) => void; validate: () => boolean; deleteStore: () => void; errors: Record; @@ -43,6 +49,12 @@ export const useAssignmentConfig = createWithEqualityFn< retakeAttemptCoolDownMinutes: 1, passingGrade: 50, displayOrder: "DEFINED", + questionControls: { + allowCopy: false, + allowPaste: false, + allowRightClick: false, + preventPrint: false, + }, strictTimeLimit: false, updatedAt: undefined, graded: false, @@ -63,6 +75,8 @@ export const useAssignmentConfig = createWithEqualityFn< setShowSubmissionFeedback: (showSubmissionFeedback: boolean) => set({ showSubmissionFeedback }), setShowQuestions: (showQuestions: boolean) => set({ showQuestions }), + setQuestionControls: (questionControls: QuestionControls) => + set({ questionControls }), setGraded: (graded) => set({ graded }), setNumAttempts: (numAttempts) => set({ @@ -163,6 +177,12 @@ export const useAssignmentConfig = createWithEqualityFn< questionDisplay: QuestionDisplayType.ONE_PER_PAGE, timeEstimateMinutes: undefined, allotedTimeMinutes: undefined, + questionControls: { + allowCopy: false, + allowPaste: false, + allowRightClick: false, + preventPrint: false, + }, })), setAssignmentConfigStore: (state) => diff --git a/apps/web/stores/learner.ts b/apps/web/stores/learner.ts index 0faadbf5..957efd76 100644 --- a/apps/web/stores/learner.ts +++ b/apps/web/stores/learner.ts @@ -1007,7 +1007,7 @@ export const useAssignmentDetails = createWithEqualityFn< setGrade: (grade) => set({ grade }), }), { - name: "learner", + name: "learner-assignment-details", trace: true, traceLimit: 25, enabled: process.env.NODE_ENV === "development", diff --git a/package.json b/package.json index 77175e82..4be0323a 100644 --- a/package.json +++ b/package.json @@ -106,11 +106,18 @@ "zod": "^3.23.5" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "add": "^2.0.6", "detect-secrets": "^1.0.6", "dotenv-cli": "^8.0.0", + "jest-environment-jsdom": "^30.2.0", "prettier": "^3.5.3", "prettier-plugin-sh": "^0.17.4", - "turbo": "^2.0.3" + "turbo": "^2.0.3", + "yarn": "^1.22.22" }, "resolutions": { "**/express": "^4.19.2", diff --git a/yarn.lock b/yarn.lock index 320533ee..c1f366d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.4.0": + version "4.4.4" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9" + integrity sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg== + "@ai-sdk/openai@^1.3.6": version "1.3.22" resolved "https://registry.yarnpkg.com/@ai-sdk/openai/-/openai-1.3.22.tgz#ed52af8f8fb3909d108e945d12789397cb188b9b" @@ -572,7 +577,7 @@ resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz#92d792a7dda250dfcb902e13228f37a81be57c8f" integrity sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw== -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -1839,6 +1844,29 @@ slash "^3.0.0" strip-ansi "^6.0.0" +"@jest/environment-jsdom-abstract@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz#1313f9b3b509c31298c241203161b36622865181" + integrity sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/jsdom" "^21.1.7" + "@types/node" "*" + jest-mock "30.2.0" + jest-util "30.2.0" + +"@jest/environment@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.2.0.tgz#1e673cdb8b93ded707cf6631b8353011460831fa" + integrity sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g== + dependencies: + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + jest-mock "30.2.0" + "@jest/environment@^29.7.0": version "29.7.0" resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" @@ -1864,6 +1892,18 @@ expect "^29.7.0" jest-snapshot "^29.7.0" +"@jest/fake-timers@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.2.0.tgz#0941ddc28a339b9819542495b5408622dc9e94ec" + integrity sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw== + dependencies: + "@jest/types" "30.2.0" + "@sinonjs/fake-timers" "^13.0.0" + "@types/node" "*" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + "@jest/fake-timers@^29.7.0": version "29.7.0" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" @@ -1886,6 +1926,14 @@ "@jest/types" "^29.6.3" jest-mock "^29.7.0" +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== + dependencies: + "@types/node" "*" + jest-regex-util "30.0.1" + "@jest/reporters@^29.7.0": version "29.7.0" resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" @@ -1916,6 +1964,13 @@ strip-ansi "^6.0.0" v8-to-istanbul "^9.0.1" +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== + dependencies: + "@sinclair/typebox" "^0.34.0" + "@jest/schemas@^29.6.3": version "29.6.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" @@ -1973,6 +2028,19 @@ slash "^3.0.0" write-file-atomic "^4.0.2" +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + "@jest/types@^29.6.3": version "29.6.3" resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" @@ -3362,12 +3430,17 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@sinclair/typebox@^0.34.0": + version "0.34.41" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.41.tgz#aa51a6c1946df2c5a11494a2cdb9318e026db16c" + integrity sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g== + "@sindresorhus/merge-streams@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== -"@sinonjs/commons@^3.0.0": +"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== @@ -3381,6 +3454,13 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@sinonjs/fake-timers@^13.0.0": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@smastrom/react-rating@^1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@smastrom/react-rating/-/react-rating-1.5.0.tgz#c7e7d3ae3334d6488a9af7b7d23578d49631d0ae" @@ -3938,6 +4018,44 @@ node-fetch "~2.6.1" seedrandom "^3.0.5" +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" + integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/react@^16.3.0": + version "16.3.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.0.tgz#3a85bb9bdebf180cd76dba16454e242564d598a6" + integrity sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw== + dependencies: + "@babel/runtime" "^7.12.5" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + "@tippyjs/react@^4.2.6": version "4.2.6" resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71" @@ -4380,6 +4498,11 @@ dependencies: tslib "^2.4.0" +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -4545,7 +4668,7 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -4557,7 +4680,7 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.0", "@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== @@ -4577,6 +4700,15 @@ resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3" integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== +"@types/jsdom@^21.1.7": + version "21.1.7" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.7.tgz#9edcb09e0b07ce876e7833922d3274149c898cfa" + integrity sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + "@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -4849,7 +4981,7 @@ resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -4897,7 +5029,7 @@ dependencies: "@types/node" "*" -"@types/tough-cookie@^4.0.0": +"@types/tough-cookie@*", "@types/tough-cookie@^4.0.0": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== @@ -4978,6 +5110,13 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== +"@types/yargs@^17.0.33": + version "17.0.34" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.34.tgz#1c2f9635b71d5401827373a01ce2e8a7670ea839" + integrity sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^17.0.8": version "17.0.33" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.33.tgz#8c32303da83eec050a84b3c7ae7b9f922d13e32d" @@ -5602,6 +5741,11 @@ acorn@^8.11.0, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +add@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235" + integrity sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q== + afinn-165-financialmarketnews@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/afinn-165-financialmarketnews/-/afinn-165-financialmarketnews-3.0.0.tgz#cf422577775bf94f9bc156f3f001a1f29338c3d8" @@ -5721,7 +5865,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.0.0, ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== @@ -5790,7 +5934,14 @@ aria-hidden@^1.2.4: dependencies: tslib "^2.0.0" -aria-query@^5.3.2: +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +aria-query@^5.0.0, aria-query@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== @@ -6612,6 +6763,11 @@ ci-info@^3.2.0, ci-info@^3.8.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +ci-info@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" + integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== + cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: version "1.4.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" @@ -7140,6 +7296,11 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -7455,6 +7616,16 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -10479,6 +10650,17 @@ jest-each@^29.7.0: jest-util "^29.7.0" pretty-format "^29.7.0" +jest-environment-jsdom@^30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz#e95e0921ed22be974f1d8a324766d12b1844cb2c" + integrity sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ== + dependencies: + "@jest/environment" "30.2.0" + "@jest/environment-jsdom-abstract" "30.2.0" + "@types/jsdom" "^21.1.7" + "@types/node" "*" + jsdom "^26.1.0" + jest-environment-node@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" @@ -10533,6 +10715,21 @@ jest-matcher-utils@^29.7.0: jest-get-type "^29.6.3" pretty-format "^29.7.0" +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + stack-utils "^2.0.6" + jest-message-util@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" @@ -10548,6 +10745,15 @@ jest-message-util@^29.7.0: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + jest-util "30.2.0" + jest-mock@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" @@ -10562,6 +10768,11 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + jest-regex-util@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" @@ -10671,6 +10882,18 @@ jest-snapshot@^29.7.0: pretty-format "^29.7.0" semver "^7.5.3" +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + jest-util@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" @@ -11354,6 +11577,11 @@ luxon@^3.2.1, luxon@~3.6.0: resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.6.1.tgz#d283ffc4c0076cb0db7885ec6da1c49ba97e47b0" integrity sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@0.30.8: version "0.30.8" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.8.tgz#14e8624246d2bedba70d5462aa99ac9681844613" @@ -13134,7 +13362,7 @@ pgpass@1.0.5: dependencies: split2 "^4.1.0" -picocolors@^1.0.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -13369,6 +13597,24 @@ prettier@^3.5.3: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.1.tgz#cc3bce21c09a477b1e987b76ce9663925d86ae44" integrity sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A== +pretty-format@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== + dependencies: + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" + +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" @@ -13729,7 +13975,12 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^18.0.0: +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-is@^18.0.0, react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -13977,6 +14228,14 @@ recast@^0.23.11: tiny-invariant "^1.3.3" tslib "^2.0.1" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + redis-errors@^1.0.0, redis-errors@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" @@ -15028,7 +15287,7 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== -stack-utils@^2.0.3: +stack-utils@^2.0.3, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -16954,6 +17213,11 @@ yargs@^17.0.0, yargs@^17.3.1, yargs@^17.7.2: y18n "^5.0.5" yargs-parser "^21.1.1" +yarn@^1.22.22: + version "1.22.22" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.22.tgz#ac34549e6aa8e7ead463a7407e1c7390f61a6610" + integrity sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg== + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" From a3e166b9628ff5457e4457bd7fba67929e8cab78 Mon Sep 17 00:00:00 2001 From: Magdy-Hafez#030901 <113151015+MmagdyHafezZ@users.noreply.github.com> Date: Sat, 15 Nov 2025 15:17:32 -0700 Subject: [PATCH 2/3] feat: xlsx support feature (#205) --- .../app/author/(components)/ImportModal.tsx | 243 +++++++-- .../ImportModal.xlsx.integration.test.tsx | 509 ++++++++++++++++++ apps/web/components/MarkDownEditor.tsx | 10 +- apps/web/jest.setup.js | 16 +- apps/web/package.json | 2 +- yarn.lock | 62 ++- 6 files changed, 788 insertions(+), 54 deletions(-) create mode 100644 apps/web/app/author/(components)/__tests__/ImportModal.xlsx.integration.test.tsx diff --git a/apps/web/app/author/(components)/ImportModal.tsx b/apps/web/app/author/(components)/ImportModal.tsx index 7e29ee5c..111c7e4f 100644 --- a/apps/web/app/author/(components)/ImportModal.tsx +++ b/apps/web/app/author/(components)/ImportModal.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/strings"; import type { QuestionAuthorStore, QuestionType } from "@/config/types"; import { generateTempQuestionId } from "@/lib/utils"; import { ResponseType } from "@/config/types"; +import * as XLSX from "xlsx"; interface ImportModalProps { isOpen: boolean; onClose: () => void; @@ -30,6 +31,7 @@ interface ImportOptions { importRubrics: boolean; importConfig: boolean; importAssignmentSettings: boolean; + importChoiceFeedback: boolean; } interface ParsedData { @@ -60,11 +62,12 @@ const ImportModal: React.FC = ({ const [importOptions, setImportOptions] = useState({ replaceExisting: false, appendToExisting: true, - validateQuestions: true, + validateQuestions: false, importChoices: true, importRubrics: true, importConfig: false, importAssignmentSettings: false, + importChoiceFeedback: true, }); const [isProcessing, setIsProcessing] = useState(false); const [importStep, setImportStep] = useState< @@ -386,37 +389,47 @@ const ImportModal: React.FC = ({ setIsProcessing(true); try { - const text = await file.text(); let data: ParsedData; - if (file.name.endsWith(".json")) { - data = JSON.parse(text) as ParsedData; - } else if (file.name.endsWith(".txt")) { - if ( - text.includes("COURSERA ASSIGNMENT EXPORT") || - text.includes("[ASSIGNMENT_METADATA]") || - text.includes("[QUESTIONS]") - ) { - data = parseCoursera(text); + if (file.name.endsWith(".xlsx") || file.name.endsWith(".xls")) { + data = await parseXLSX(file); + setImportOptions((prev) => ({ + ...prev, + importChoices: true, + validateQuestions: true, + })); + } else { + const text = await file.text(); + + if (file.name.endsWith(".json")) { + data = JSON.parse(text) as ParsedData; + } else if (file.name.endsWith(".txt")) { + if ( + text.includes("COURSERA ASSIGNMENT EXPORT") || + text.includes("[ASSIGNMENT_METADATA]") || + text.includes("[QUESTIONS]") + ) { + data = parseCoursera(text); + } else { + throw new Error( + "Unrecognized text file format. Expected Coursera format with section headers like [QUESTIONS].", + ); + } + } else if (file.name.endsWith(".xml")) { + data = parseOLX(text); + } else if (file.name.endsWith(".docx")) { + throw new Error( + "Microsoft Word documents not yet supported. Please export as text, YAML, or XML.", + ); + } else if (file.name.endsWith(".zip")) { + throw new Error( + "IMS QTI zip files not yet supported. Please extract individual XML files from the package.", + ); } else { throw new Error( - "Unrecognized text file format. Expected Coursera format with section headers like [QUESTIONS].", + "Unsupported file format. Please use JSON, Excel (.xlsx), Coursera (.txt), QTI (.xml), or OLX (.xml) files.", ); } - } else if (file.name.endsWith(".xml")) { - data = parseOLX(text); - } else if (file.name.endsWith(".docx")) { - throw new Error( - "Microsoft Word documents not yet supported. Please export as text, YAML, or XML.", - ); - } else if (file.name.endsWith(".zip")) { - throw new Error( - "IMS QTI zip files not yet supported. Please extract individual XML files from the package.", - ); - } else { - throw new Error( - "Unsupported file format. Please use JSON, Coursera (.txt), QTI (.xml), or OLX (.xml) files.", - ); } if (!data.questions || data.questions.length === 0) { @@ -433,7 +446,6 @@ const ImportModal: React.FC = ({ setImportStep("configure"); } catch (error) { - console.error("File parsing error:", error); alert( `Failed to parse file: ${error instanceof Error ? error.message : "Unknown error"}`, ); @@ -463,7 +475,6 @@ const ImportModal: React.FC = ({ "Unrecognized YAML format. Expected either Coursera variations format or custom export format.", ); } catch (error) { - console.error("Error parsing YAML:", error); throw new Error( `Invalid YAML format: ${error instanceof Error ? error.message : "Unknown error"}`, ); @@ -802,6 +813,124 @@ const ImportModal: React.FC = ({ }; }; + const parseXLSX = async (file: File): Promise => { + const questions: QuestionAuthorStore[] = []; + + try { + const data = await file.arrayBuffer(); + const workbook = XLSX.read(data, { type: "array" }); + + const quizSheetName = + workbook.SheetNames.find((name) => + name.toLowerCase().includes("quiz questions"), + ) || + workbook.SheetNames.find((name) => + name.toLowerCase().includes("questions_master"), + ) || + workbook.SheetNames[0]; + + const worksheet = workbook.Sheets[quizSheetName]; + + if (!worksheet) { + throw new Error( + "Could not find a sheet with quiz questions. Expected a sheet named 'QUIZ QUESTIONS_MASTER'.", + ); + } + + const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: "", + }); + + if (!rows.length) { + throw new Error("Excel sheet is empty."); + } + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + + if (!row || row.length === 0) continue; + + const questionText = (row[0] ?? "").toString().trim(); + const correctAnswer = (row[1] ?? "").toString().trim(); + const answer2 = (row[2] ?? "").toString().trim(); + const answer3 = (row[3] ?? "").toString().trim(); + const answer4 = (row[4] ?? "").toString().trim(); + const answerLocation = (row[5] ?? "").toString().trim(); + const additionalInfo = (row[6] ?? "").toString().trim(); + + if (!questionText) continue; + + const question: Partial = { + id: generateTempQuestionId(), + alreadyInBackend: false, + assignmentId: 0, + index: questions.length + 1, + numRetries: 1, + type: "SINGLE_CORRECT" as QuestionType, + responseType: "OTHER" as ResponseType, + totalPoints: 1, + question: questionText, + scoring: { type: "CRITERIA_BASED", criteria: [] }, + }; + + const choices: any[] = []; + + if (correctAnswer) { + choices.push({ + choice: correctAnswer, + isCorrect: true, + points: 1, + feedback: additionalInfo + ? `You may find answer for this question at ${additionalInfo}` + : "", + }); + } + + [answer2, answer3, answer4].forEach((answer) => { + if (answer) { + choices.push({ + choice: answer, + isCorrect: false, + points: 0, + feedback: answerLocation + ? `You may find answer for this question at ${answerLocation}` + : "", + }); + } + }); + + if (choices.length < 2) { + continue; + } + + question.choices = choices; + + questions.push(question as QuestionAuthorStore); + } + + if (!questions.length) { + throw new Error( + "No questions parsed from Excel. Check that 'QUIZ QUESTIONS_MASTER' has data under the header row.", + ); + } + + return { + questions, + assignment: { + name: "Imported from Excel", + introduction: `Imported ${questions.length} multiple-choice questions from sheet "${quizSheetName}" in ${file.name}`, + }, + }; + } catch (error) { + throw new Error( + `Failed to parse Excel file: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + const parseCustomYAMLFormat = (yamlData: any): ParsedData => { const questions: QuestionAuthorStore[] = []; let assignment: any = {}; @@ -1182,6 +1311,16 @@ const ImportModal: React.FC = ({ })); } + if (!importOptions.importChoiceFeedback && importOptions.importChoices) { + questionsToImport = questionsToImport.map((q) => ({ + ...q, + choices: q.choices?.map((choice) => ({ + ...choice, + feedback: "", + })), + })); + } + if (!importOptions.importRubrics) { questionsToImport = questionsToImport.map((q) => ({ ...q, @@ -1256,13 +1395,13 @@ const ImportModal: React.FC = ({

- Supports JSON, Open edX (.xml) + Supports JSON, Excel (.xlsx), Open edX (.xml)

@@ -1299,6 +1438,10 @@ const ImportModal: React.FC = ({ JSON: Complete assignment exports with all question data +
  • + Excel (.xlsx): Quiz format with question + text and 4 answer choices (first column is correct answer) +
  • Open edX OLX (.xml): Open Learning XML format @@ -1391,8 +1534,23 @@ const ImportModal: React.FC = ({ { id: "importChoices", label: "Import question choices", - description: "Include multiple choice options", + description: + selectedFile?.name.endsWith(".xlsx") || + selectedFile?.name.endsWith(".xls") + ? "Required for Excel imports (always enabled)" + : "Include multiple choice options", }, + ...(selectedFile?.name.endsWith(".xlsx") || + selectedFile?.name.endsWith(".xls") + ? [ + { + id: "importChoiceFeedback", + label: "Import choice feedback from Excel", + description: + "Adds 'Additional Info' as feedback on correct answer, and 'Answer Location' as feedback on incorrect answers", + }, + ] + : []), { id: "importRubrics", label: "Import rubrics and scoring", @@ -1444,6 +1602,29 @@ const ImportModal: React.FC = ({
  • + {validationErrors.length > 0 && ( +
    +
    + +

    + Validation Errors +

    +
    +
    + {validationErrors.map((error, idx) => ( +
    + Question {error.questionIndex + 1}:{" "} + {error.message} (field: {error.field}) +
    + ))} +
    +

    + Note: Errors marked with "will be auto-generated" or "will + be auto-calculated" won't prevent import. +

    +
    + )} + {importOptions.replaceExisting && (
    diff --git a/apps/web/app/author/(components)/__tests__/ImportModal.xlsx.integration.test.tsx b/apps/web/app/author/(components)/__tests__/ImportModal.xlsx.integration.test.tsx new file mode 100644 index 00000000..56ba274c --- /dev/null +++ b/apps/web/app/author/(components)/__tests__/ImportModal.xlsx.integration.test.tsx @@ -0,0 +1,509 @@ +/** + * @jest-environment jsdom + * + * Integration tests for Excel import functionality + * Tests the complete flow from file selection to import + */ + +import { generateTempQuestionId } from "@/lib/utils"; +import { ResponseType, QuestionType } from "@/config/types"; + +jest.mock("@/lib/utils", () => ({ + generateTempQuestionId: jest.fn(() => Math.random()), +})); + +describe("ImportModal - Excel Import Integration", () => { + const parseExcelRows = ( + rows: any[][], + options: { importChoiceFeedback: boolean } = { importChoiceFeedback: true }, + ) => { + const questions: any[] = []; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + + if (!row || row.length === 0) continue; + + const questionText = (row[0] ?? "").toString().trim(); + const correctAnswer = (row[1] ?? "").toString().trim(); + const answer2 = (row[2] ?? "").toString().trim(); + const answer3 = (row[3] ?? "").toString().trim(); + const answer4 = (row[4] ?? "").toString().trim(); + const answerLocation = (row[5] ?? "").toString().trim(); + const additionalInfo = (row[6] ?? "").toString().trim(); + + if (!questionText) continue; + + const question: any = { + id: generateTempQuestionId(), + alreadyInBackend: false, + assignmentId: 0, + index: questions.length + 1, + numRetries: 1, + type: "SINGLE_CORRECT" as QuestionType, + responseType: "OTHER" as ResponseType, + totalPoints: 1, + question: questionText, + scoring: { type: "CRITERIA_BASED", criteria: [] }, + }; + + const choices: any[] = []; + + if (correctAnswer) { + choices.push({ + choice: correctAnswer, + isCorrect: true, + points: 1, + feedback: + options.importChoiceFeedback && additionalInfo + ? `You may find answer for this question at ${additionalInfo}` + : "", + }); + } + + [answer2, answer3, answer4].forEach((answer) => { + if (answer) { + choices.push({ + choice: answer, + isCorrect: false, + points: 0, + feedback: + options.importChoiceFeedback && answerLocation + ? `You may find answer for this question at ${answerLocation}` + : "", + }); + } + }); + + if (choices.length < 2) { + continue; + } + + question.choices = choices; + questions.push(question); + } + + return questions; + }; + + describe("Basic Parsing", () => { + it("should parse valid Excel rows into questions", () => { + const rows = [ + [ + "Question text", + "CORRECT ANSWER", + "Answer 2", + "Answer 3", + "Answer 4", + "Answer location", + "Additional Info", + ], + [ + "What is 2+2?", + "4", + "3", + "5", + "22", + "Math basics, page 1", + "This is basic arithmetic", + ], + [ + "What is the capital of France?", + "Paris", + "London", + "Berlin", + "Madrid", + "Geography book, chapter 3", + "Paris is the largest city in France", + ], + ]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(2); + expect(questions[0].question).toBe("What is 2+2?"); + expect(questions[1].question).toBe("What is the capital of France?"); + }); + + it("should skip rows without question text", () => { + const rows = [ + ["Question text", "CORRECT ANSWER", "Answer 2", "Answer 3", "Answer 4"], + ["What is 2+2?", "4", "3", "5", "22"], + ["", "Answer", "Wrong1", "Wrong2", "Wrong3"], + ["Valid question?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(2); + expect(questions[0].question).toBe("What is 2+2?"); + expect(questions[1].question).toBe("Valid question?"); + }); + + it("should skip questions with less than 2 choices", () => { + const rows = [ + ["Question text", "CORRECT ANSWER", "Answer 2", "Answer 3", "Answer 4"], + ["Question with only correct answer?", "Yes", "", "", ""], + ["Valid question?", "Yes", "No", "", ""], + ]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(1); + expect(questions[0].question).toBe("Valid question?"); + }); + + it("should handle rows with missing optional columns", () => { + const rows = [ + ["Question text", "CORRECT ANSWER", "Answer 2"], + ["Simple question?", "Yes", "No"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(1); + expect(questions[0].choices).toHaveLength(2); + }); + }); + + describe("Choice Feedback with importChoiceFeedback=true", () => { + it("should add Additional Info as feedback on correct answer", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + [ + "Test question?", + "Correct answer", + "Wrong 1", + "Wrong 2", + "Wrong 3", + "Some location", + "This is additional info for correct answer", + ], + ]; + + const questions = parseExcelRows(rows, { importChoiceFeedback: true }); + + expect(questions).toHaveLength(1); + const correctChoice = questions[0].choices.find((c: any) => c.isCorrect); + + expect(correctChoice.feedback).toContain( + "This is additional info for correct answer", + ); + }); + + it("should add Answer Location as feedback on incorrect answers", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + [ + "Test question?", + "Correct answer", + "Wrong 1", + "Wrong 2", + "Wrong 3", + "Chapter 5, Page 42", + "Some info", + ], + ]; + + const questions = parseExcelRows(rows, { importChoiceFeedback: true }); + + expect(questions).toHaveLength(1); + const incorrectChoices = questions[0].choices.filter( + (c: any) => !c.isCorrect, + ); + + incorrectChoices.forEach((choice: any) => { + expect(choice.feedback).toContain("Chapter 5, Page 42"); + }); + }); + + it("should handle empty feedback columns gracefully", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + [ + "Test question?", + "Correct answer", + "Wrong 1", + "Wrong 2", + "Wrong 3", + "", + "", + ], + ]; + + const questions = parseExcelRows(rows, { importChoiceFeedback: true }); + + expect(questions).toHaveLength(1); + questions[0].choices.forEach((choice: any) => { + expect(choice.feedback).toBe(""); + }); + }); + + it("should use feedback format with 'You may find answer' prefix", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + [ + "Test?", + "Yes", + "No", + "Maybe", + "Not sure", + "Page 10", + "Additional info", + ], + ]; + + const questions = parseExcelRows(rows, { importChoiceFeedback: true }); + + const correctChoice = questions[0].choices.find((c: any) => c.isCorrect); + const incorrectChoice = questions[0].choices.find( + (c: any) => !c.isCorrect, + ); + + expect(correctChoice.feedback).toBe( + "You may find answer for this question at Additional info", + ); + expect(incorrectChoice.feedback).toBe( + "You may find answer for this question at Page 10", + ); + }); + }); + + describe("Choice Feedback with importChoiceFeedback=false", () => { + it("should not add any feedback when option is disabled", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + [ + "Test question?", + "Correct answer", + "Wrong 1", + "Wrong 2", + "Wrong 3", + "Chapter 5, Page 42", + "This is additional info", + ], + ]; + + const questions = parseExcelRows(rows, { importChoiceFeedback: false }); + + expect(questions).toHaveLength(1); + questions[0].choices.forEach((choice: any) => { + expect(choice.feedback).toBe(""); + }); + }); + }); + + describe("Question Properties", () => { + it("should set question type to SINGLE_CORRECT", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].type).toBe("SINGLE_CORRECT"); + }); + + it("should set totalPoints to 1", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].totalPoints).toBe(1); + }); + + it("should mark first choice as correct with points=1", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + const correctChoice = questions[0].choices[0]; + expect(correctChoice.isCorrect).toBe(true); + expect(correctChoice.points).toBe(1); + expect(correctChoice.choice).toBe("Yes"); + }); + + it("should mark other choices as incorrect with points=0", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + const incorrectChoices = questions[0].choices.slice(1); + incorrectChoices.forEach((choice: any) => { + expect(choice.isCorrect).toBe(false); + expect(choice.points).toBe(0); + }); + }); + + it("should include all 4 choices when all are provided", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "Maybe", "Not sure"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].choices).toHaveLength(4); + expect(questions[0].choices.map((c: any) => c.choice)).toEqual([ + "Yes", + "No", + "Maybe", + "Not sure", + ]); + }); + + it("should only include provided choices (can be less than 4)", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Test?", "Yes", "No", "", ""], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].choices).toHaveLength(2); + expect(questions[0].choices.map((c: any) => c.choice)).toEqual([ + "Yes", + "No", + ]); + }); + }); + + describe("Multiple Questions", () => { + it("should parse all valid questions from multiple rows", () => { + const rows = [ + [ + "Question", + "Correct", + "Wrong1", + "Wrong2", + "Wrong3", + "Location", + "Info", + ], + ["Q1?", "A1", "B1", "C1", "D1", "Loc1", "Info1"], + ["Q2?", "A2", "B2", "C2", "D2", "Loc2", "Info2"], + ["Q3?", "A3", "B3", "C3", "D3", "Loc3", "Info3"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(3); + expect(questions[0].question).toBe("Q1?"); + expect(questions[1].question).toBe("Q2?"); + expect(questions[2].question).toBe("Q3?"); + }); + + it("should assign correct index to each question", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + ["Q1?", "A1", "B1", "C1", "D1"], + ["Q2?", "A2", "B2", "C2", "D2"], + ["Q3?", "A3", "B3", "C3", "D3"], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].index).toBe(1); + expect(questions[1].index).toBe(2); + expect(questions[2].index).toBe(3); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty rows array", () => { + const rows: any[][] = []; + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(0); + }); + + it("should handle only header row", () => { + const rows = [["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"]]; + + const questions = parseExcelRows(rows); + + expect(questions).toHaveLength(0); + }); + + it("should trim whitespace from question text and answers", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + [" What is 2+2? ", " 4 ", " 3 ", " 5 ", " 22 "], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].question).toBe("What is 2+2?"); + expect(questions[0].choices[0].choice).toBe("4"); + expect(questions[0].choices[1].choice).toBe("3"); + }); + + it("should handle special characters in question text", () => { + const rows = [ + ["Question", "Correct", "Wrong1", "Wrong2", "Wrong3"], + [ + "What's the difference between and ?", + "Semantic meaning", + "Visual appearance", + "Browser support", + "No difference", + ], + ]; + + const questions = parseExcelRows(rows); + + expect(questions[0].question).toContain(""); + expect(questions[0].question).toContain(""); + }); + }); +}); diff --git a/apps/web/components/MarkDownEditor.tsx b/apps/web/components/MarkDownEditor.tsx index 6c3264b9..1011d1eb 100644 --- a/apps/web/components/MarkDownEditor.tsx +++ b/apps/web/components/MarkDownEditor.tsx @@ -45,7 +45,6 @@ const MarkdownEditor: React.FC = ({ ); const [charCount, setCharCount] = useState(value?.length ?? 0); - // Initialize Quill useEffect(() => { let isMounted = true; const initializeQuill = async () => { @@ -131,7 +130,6 @@ const MarkdownEditor: React.FC = ({ }; }, [quillInstance]); - // Prevent paste (strongest method — works 100%) useEffect(() => { if (!quillInstance) return; @@ -150,7 +148,6 @@ const MarkdownEditor: React.FC = ({ } }; - // capture:true ensures we intercept BEFORE Quill document.addEventListener("paste", handlePaste, true); return () => { @@ -158,7 +155,6 @@ const MarkdownEditor: React.FC = ({ }; }, [quillInstance, allowPaste]); - // Prevent right-click if needed useEffect(() => { if (!quillInstance || allowRightClick) return; @@ -174,7 +170,6 @@ const MarkdownEditor: React.FC = ({ return () => root.removeEventListener("contextmenu", handleContextMenu); }, [quillInstance, allowRightClick]); - // Sync value externally useEffect(() => { if (quillInstance) { const currentHTML = quillInstance.root.innerHTML; @@ -184,7 +179,6 @@ const MarkdownEditor: React.FC = ({ } }, [quillInstance, value]); - // Style injection useEffect(() => { const style = document.createElement("style"); style.innerHTML = ` @@ -228,7 +222,9 @@ const MarkdownEditor: React.FC = ({ `; document.head.appendChild(style); - return () => document.head.removeChild(style); + return () => { + document.head.removeChild(style); + }; }, []); return ( diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js index 9643751d..ad1f9d43 100644 --- a/apps/web/jest.setup.js +++ b/apps/web/jest.setup.js @@ -1,11 +1,6 @@ -// Optional: configure or set up a testing framework before each test. -// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` +import "@testing-library/jest-dom"; -// Setup for Node.js environment tests -import '@testing-library/jest-dom'; - -// Polyfill for ClipboardEvent (not available in jsdom) -if (typeof global.ClipboardEvent === 'undefined') { +if (typeof global.ClipboardEvent === "undefined") { global.ClipboardEvent = class ClipboardEvent extends Event { constructor(type, eventInitDict) { super(type, eventInitDict); @@ -14,11 +9,10 @@ if (typeof global.ClipboardEvent === 'undefined') { }; } -// Mock matchMedia (not available in jsdom) -if (typeof window !== 'undefined') { - Object.defineProperty(window, 'matchMedia', { +if (typeof window !== "undefined") { + Object.defineProperty(window, "matchMedia", { writable: true, - value: jest.fn().mockImplementation(query => ({ + value: jest.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, diff --git a/apps/web/package.json b/apps/web/package.json index 6915b48c..ded144d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -85,7 +85,7 @@ "styled-components": "^6.1.14", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "xlsx": "^0.18.5", "zod": "^3.24.2", "zustand": "^4.4.1" }, diff --git a/yarn.lock b/yarn.lock index c1f366d1..57926b53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5746,6 +5746,11 @@ add@^2.0.6: resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235" integrity sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q== +adler-32@~1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2" + integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A== + afinn-165-financialmarketnews@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/afinn-165-financialmarketnews/-/afinn-165-financialmarketnews-3.0.0.tgz#cf422577775bf94f9bc156f3f001a1f29338c3d8" @@ -6628,6 +6633,14 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== +cfb@~1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44" + integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA== + dependencies: + adler-32 "~1.3.0" + crc-32 "~1.2.0" + chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -6900,6 +6913,11 @@ code-block-writer@^13.0.3: resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b" integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg== +codepage@~1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab" + integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA== + collect-v8-coverage@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" @@ -7197,6 +7215,11 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +crc-32@~1.2.0, crc-32@~1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -8945,6 +8968,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +frac@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b" + integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA== + fraction.js@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" @@ -15250,6 +15278,13 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +ssf@~0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c" + integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g== + dependencies: + frac "~1.1.2" + sshpk@^1.14.1, sshpk@^1.7.0: version "1.18.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" @@ -17060,11 +17095,21 @@ winston@^3.9.0: triple-beam "^1.3.0" winston-transport "^4.9.0" +wmf@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da" + integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw== + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +word@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961" + integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA== + wordnet-db@^3.1.11: version "3.1.14" resolved "https://registry.yarnpkg.com/wordnet-db/-/wordnet-db-3.1.14.tgz#7ba1ec2cb5730393f0856efcc738a60085426199" @@ -17129,14 +17174,23 @@ ws@~8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== +xlsx@^0.18.5: + version "0.18.5" + resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0" + integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ== + dependencies: + adler-32 "~1.3.0" + cfb "~1.2.1" + codepage "~1.15.0" + crc-32 "~1.2.1" + ssf "~0.11.2" + wmf "~1.0.1" + word "~0.3.0" + "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version "0.20.2" resolved "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz#0f64eeed3f1a46e64724620c3553f2dbd3cd2d7d" -"xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz": - version "0.20.3" - resolved "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz#992725af0bb69fa9733590fcb8ab4a57e10b929b" - xml-name-validator@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" From 76c5e5471314dbcd1fe346b48260c28a11aed888 Mon Sep 17 00:00:00 2001 From: Magdy-Hafez#030901 <113151015+MmagdyHafezZ@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:34:12 -0700 Subject: [PATCH 3/3] fix date issue (#207) --- .../serialize-dates.interceptor.ts | 42 +++++++++++++++++++ apps/api/src/main.ts | 7 ++++ 2 files changed, 49 insertions(+) create mode 100644 apps/api/src/common/interceptors/serialize-dates.interceptor.ts diff --git a/apps/api/src/common/interceptors/serialize-dates.interceptor.ts b/apps/api/src/common/interceptors/serialize-dates.interceptor.ts new file mode 100644 index 00000000..229659ed --- /dev/null +++ b/apps/api/src/common/interceptors/serialize-dates.interceptor.ts @@ -0,0 +1,42 @@ +/* eslint-disable */ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +@Injectable() +export class SerializeDatesInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe(map((data) => this.serializeDates(data))); + } + + private serializeDates(object: any): any { + if (object === null || object === undefined) { + return object; + } + + if (object instanceof Date) { + return object.toISOString(); + } + + if (Array.isArray(object)) { + return object.map((item) => this.serializeDates(item)); + } + + if (typeof object === "object") { + const serialized: any = {}; + for (const key in object) { + if (object.hasOwnProperty(key)) { + serialized[key] = this.serializeDates(object[key]); + } + } + return serialized; + } + + return object; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d44e244e..3ad5eab9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -27,6 +27,7 @@ import { AppModule } from "./app.module"; import { AuthModule } from "./auth/auth.module"; import { RolesGlobalGuard } from "./auth/role/roles.global.guard"; import { winstonOptions } from "./logger/config"; +import { SerializeDatesInterceptor } from "./common/interceptors/serialize-dates.interceptor"; if (process.env.NODE_ENV === "production") { instana({ @@ -103,6 +104,12 @@ async function bootstrap() { */ app.useGlobalGuards(app.select(AuthModule).get(RolesGlobalGuard)); + /** + * Global serialization interceptor + * Automatically serializes Date objects to ISO strings in API responses + */ + app.useGlobalInterceptors(new SerializeDatesInterceptor()); + /** * Swagger API documentation setup * Provides interactive API documentation at /api endpoint