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