From c7ebfb50e7487261177f7c9fc7d4af071efea28a Mon Sep 17 00:00:00 2001 From: MmagdyhafezZ Date: Fri, 14 Nov 2025 21:58:23 -0700 Subject: [PATCH] feat: xlsx support feature --- .../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"