From 0739e044a1c6b03f9aed93d855a475be3c7c7818 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 5 Jan 2025 17:46:11 +0000 Subject: [PATCH 1/4] ENCOA-307 --- .../SectionRenderer/SectionContext/level.tsx | 3 +- .../SectionContext/reading.tsx | 9 +- .../SectionExercises/index.tsx | 244 +++++++++--------- .../SettingsEditor/Shared/SectionPicker.tsx | 6 +- .../ExamEditor/SettingsEditor/level.tsx | 2 - 5 files changed, 135 insertions(+), 129 deletions(-) diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/level.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/level.tsx index 2a163b3b..abc98e05 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/level.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/level.tsx @@ -1,4 +1,3 @@ - import useExamEditorStore from "@/stores/examEditor"; import ListeningContext from "./listening"; import ReadingContext from "./reading"; @@ -30,7 +29,7 @@ const LevelContext: React.FC = ({ sectionId }) => { )} {(readingSection || listeningSection || hasReadingContext) && (
- {readingSection || hasReadingContext && } + {(readingSection !== undefined || hasReadingContext) && } {listeningSection && }
)} diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx index 424bac59..b36d2b03 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx @@ -11,11 +11,10 @@ import { Module } from "@/interfaces"; interface Props { module: Module; sectionId: number; - level?: boolean; } -const ReadingContext: React.FC = ({ sectionId, module, level = false }) => { +const ReadingContext: React.FC = ({ sectionId, module }) => { const { dispatch } = useExamEditorStore(); const sectionState = useExamEditorStore( (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! @@ -55,7 +54,7 @@ const ReadingContext: React.FC = ({ sectionId, module, level = false }) = } }); - useEffect(()=> { + useEffect(() => { if (readingPart.text === undefined) { setTitle(''); setContent(''); @@ -74,7 +73,9 @@ const ReadingContext: React.FC = ({ sectionId, module, level = false }) = useEffect(() => { - const passageRes = levelGenResults.find((res) => res.generating === "passage"); + const passageRes = [...levelGenResults].reverse() + .find((res) => res.generating === "passage"); + if (levelGenResults && passageRes) { setEditing(true); setTitle(passageRes.result[0].title); diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx index 6b4e4e2f..c6dc6f03 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx @@ -6,7 +6,7 @@ import Dropdown from "@/components/Dropdown"; import useExamEditorStore from "@/stores/examEditor"; import Writing from "../../Exercises/Writing"; import Speaking from "../../Exercises/Speaking"; -import { ReactElement, ReactNode, useEffect, useState } from "react"; +import { ReactNode, useEffect } from "react"; import { DndContext, PointerSensor, @@ -43,7 +43,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { const genResult = section?.genResult; const generating = section?.generating; - const levelGenResults = section?.levelGenResults + const levelGenResults = section?.levelGenResults; const levelGenerating = section?.levelGenerating; const sectionState = section?.state; @@ -66,134 +66,146 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { }, [genResult, dispatch, sectionId, currentModule]); - useEffect(() => { - if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) { - const newExercises = levelGenResults - .filter(res => res.generating.startsWith("exercises")) - .map(res => res.result[0].exercises) - .flat(); - - const updates = [ - { - type: "UPDATE_SECTION_STATE", - payload: { - sectionId, - module: "level", - update: { - exercises: [...(sectionState as ExamPart).exercises, ...newExercises] - } - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenerating", - value: levelGenerating?.filter(g => !g?.startsWith("exercises")) - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenResults", - value: levelGenResults.filter(res => !res.generating.startsWith("exercises")) + const handleExerciseGen = ( + results: any[], + assignExercisesFn: (results: any[]) => any[], + { + sectionId, + currentModule, + sectionState, + levelGenerating, + levelGenResults + }: { + sectionId: number; + currentModule: string; + sectionState: ExamPart; + levelGenerating?: Generating[]; + levelGenResults: any[]; + } + ) => { + const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises"); + + const updates = [ + { + type: "UPDATE_SECTION_STATE", + payload: { + sectionId, + module: "level", + update: { + exercises: [ + ...sectionState.exercises, + ...assignExercisesFn(results) + ] } } - ] as Action[]; + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenerating", + value: levelGenerating?.filter(g => + nonWritingOrSpeaking + ? !g?.startsWith("exercises") + : !results.flatMap(res => res.generating as Generating).includes(g) + ) + } + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenResults", + value: levelGenResults.filter(res => + nonWritingOrSpeaking + ? !res.generating.startsWith("exercises") + : !results.flatMap(res => res.generating as Generating).includes(res.generating) + ) + } + } + ] as Action[]; + + updates.forEach(update => dispatch(update)); + }; - updates.forEach(update => dispatch(update)); + useEffect(() => { + if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) { + const results = levelGenResults.filter(res => + res.generating.startsWith("exercises") + ); + + const assignExercises = (results: any[]) => + results + .map(res => res.result[0].exercises) + .flat(); + + handleExerciseGen( + results, + assignExercises, + { + sectionId, + currentModule, + sectionState: sectionState as ExamPart, + levelGenerating, + levelGenResults + } + ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); useEffect(() => { - if (levelGenResults && levelGenResults.some(res => res.generating === "writing_letter" || res.generating === "writing_2")) { - const results = levelGenResults.filter(res => res.generating === "writing_letter" || res.generating === "writing_2"); + if (levelGenResults && levelGenResults?.some(res => + res.generating === "writing_letter" || res.generating === "writing_2" + )) { + const results = levelGenResults.filter(res => + res.generating === "writing_letter" || res.generating === "writing_2" + ); - const updates = [ + const assignExercises = (results: any[]) => + results.map(res => ({ + ...writingTask(res.generating === "writing_letter" ? 1 : 2), + prompt: res.result[0].prompt, + variant: res.generating === "writing_letter" ? "letter" : "essay" + }) as WritingExercise); + + handleExerciseGen( + results, + assignExercises, { - type: "UPDATE_SECTION_STATE", - payload: { - sectionId, - module: "level", - update: { - exercises: [...(sectionState as ExamPart).exercises, - ...results.map((res) => { - return { - ...writingTask(res.generating === "writing_letter" ? 1 : 2), - prompt: res.result[0].prompt, - variant: res.generating === "writing_letter" ? "letter" : "essay" - } as WritingExercise; - }) - ] - } - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenerating", - value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g)) - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenResults", - value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating)) - } + sectionId, + currentModule, + sectionState: sectionState as ExamPart, + levelGenerating, + levelGenResults } - ] as Action[]; - - updates.forEach(update => dispatch(update)); + ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); useEffect(() => { - if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) { - const results = levelGenResults.filter(res => res.generating.startsWith("speaking")); - const updates = [ - { - type: "UPDATE_SECTION_STATE", - payload: { - sectionId, - module: "level", - update: { - exercises: [...(sectionState as ExamPart).exercises, - ...results.map(createSpeakingExercise) - ] - } - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenerating", - value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g)) - } - }, - { - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "levelGenResults", - value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating)) - } - } - ] as Action[]; + if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) { + const results = levelGenResults.filter(res => + res.generating.startsWith("speaking") + ); - updates.forEach(update => dispatch(update)); + const assignExercises = (results: any[]) => + results.map(createSpeakingExercise); + + handleExerciseGen( + results, + assignExercises, + { + sectionId, + currentModule, + sectionState: sectionState as ExamPart, + levelGenerating, + levelGenResults + } + ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); @@ -243,7 +255,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { const onFocus = (questionId: string, id: string | undefined) => { if (id) { - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id} } }) + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id } } }) } } @@ -284,9 +296,9 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { {currentModule === "level" && ( <> { - questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises" + questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises" && background(Generated exercises will appear here!)} - {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && } + {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && } ) } diff --git a/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx index 1e32b0b3..d572a661 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx @@ -38,11 +38,7 @@ const SectionPicker: React.FC = ({ const newValue = currentValue === value ? undefined : value; setSelectedValue(newValue); let update = {}; - if (module == "reading") { - update = { - text: undefined - } - } else { + if (module === "listening") { if (state.audio?.source) { URL.revokeObjectURL(state.audio.source) } diff --git a/src/components/ExamEditor/SettingsEditor/level.tsx b/src/components/ExamEditor/SettingsEditor/level.tsx index 79e9774e..33eb0c87 100644 --- a/src/components/ExamEditor/SettingsEditor/level.tsx +++ b/src/components/ExamEditor/SettingsEditor/level.tsx @@ -15,10 +15,8 @@ import { usePersistentExamStore } from "@/stores/exam"; import openDetachedTab from "@/utils/popout"; import ListeningComponents from "./listening/components"; import ReadingComponents from "./reading/components"; -import WritingComponents from "./writing/components"; import SpeakingComponents from "./speaking/components"; import SectionPicker from "./Shared/SectionPicker"; -import SettingsDropdown from "./Shared/SettingsDropdown"; const LevelSettings: React.FC = () => { From 61e07dae95798ed5c3a4f3d61b207ffad71ada1b Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 5 Jan 2025 19:04:08 +0000 Subject: [PATCH 2/4] ENCOA-308 --- src/pages/(admin)/Lists/BatchCreateUser.tsx | 34 ++++++++++----------- src/pages/api/batch_users.ts | 5 ++- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/pages/(admin)/Lists/BatchCreateUser.tsx b/src/pages/(admin)/Lists/BatchCreateUser.tsx index 6acc7921..78a4251f 100644 --- a/src/pages/(admin)/Lists/BatchCreateUser.tsx +++ b/src/pages/(admin)/Lists/BatchCreateUser.tsx @@ -22,6 +22,7 @@ import { IoInformationCircleOutline } from "react-icons/io5"; import { FaFileDownload } from "react-icons/fa"; import { HiOutlineDocumentText } from "react-icons/hi"; import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User"; +import { v4 } from "uuid"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -87,7 +88,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); const [showHelp, setShowHelp] = useState(false); - const [entity, setEntity] = useState<{id: string | null, label: string | null}| undefined>(() => { + const [entity, setEntity] = useState<{ id: string | null, label: string | null } | undefined>(() => { if (!entities?.length) { return undefined; } @@ -307,11 +308,11 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi })); setNewUsers(newUsersList); - const {data: emailEntityMap} = await axios.post("/api/users/controller?op=getEntities", { + const { data: emailEntityMap } = await axios.post("/api/users/controller?op=getEntities", { emails: dupes.map((x) => x.email) }); const withLabels = dupes.map((u) => ({ - ...u, + ...u, entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || [] })) setDuplicatedUsers(withLabels); @@ -339,18 +340,17 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`)) return; - Promise.all(newUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity, from: user.id }))) - .then(() => toast.success(`Successfully invited ${newUsers.length} registered student(s)!`)) - .finally(() => { - if (newUsers.length === 0) setIsLoading(false); - }); - if (newUsers.length > 0) { setIsLoading(true); - try { - await axios.post("/api/batch_users", { users: newUsers.map((user) => ({ ...user, type, expiryDate })) }); - toast.success(`Successfully added ${newUsers.length} user(s)!`); + const withIds = newUsers.map((user) => ({ ...user, type, expiryDate, id: v4() })); + await axios.post("/api/batch_users", { users: withIds}); + toast.success(`Successfully added ${withIds.length} user(s)!`); + Promise.all(withIds.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity: entity?.id, from: user.id }))) + .then(() => toast.success(`Successfully invited ${withIds.length} registered student(s)!`)) + .finally(() => { + if (withIds.length === 0) setIsLoading(false); + }); onFinish(); } catch (e) { console.error(e) @@ -486,14 +486,14 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi options={entities.map((e) => ({ value: e.id, label: e.label }))} onChange={(e) => { if (!e) { - setEntity(undefined); - return; + setEntity(undefined); + return; } setEntity({ - id: e?.value, - label: e?.label + id: e?.value, + label: e?.label }); - }} + }} isClearable={checkAccess(user, ["admin", "developer"])} /> diff --git a/src/pages/api/batch_users.ts b/src/pages/api/batch_users.ts index e6ca32c4..b64f342c 100644 --- a/src/pages/api/batch_users.ts +++ b/src/pages/api/batch_users.ts @@ -38,7 +38,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { passport_id?: string; phone: string; }; - entity?: string + entity: { id: string, label: string } entities: { id: string, role: string }[] passwordHash: string | undefined; passwordSalt: string | undefined; @@ -49,11 +49,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const salt = crypto.randomBytes(16).toString('base64'); const hash = await scrypt.hash(user.passport_id, salt); - const entity = await getEntityWithRoles(currentUser.entity!) + const entity = await getEntityWithRoles(currentUser.entity!.id) const defaultRole = findBy(entity?.roles || [], "isDefault", true) currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }] - delete currentUser.entity currentUser.email = currentUser.email.toLowerCase(); currentUser.passwordHash = hash; From 8f77f28aaa5751d8e9ca7a20a058552192996b77 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 5 Jan 2025 21:03:52 +0000 Subject: [PATCH 3/4] ENCOA-309 --- src/components/ImportSummaries/Classroom.tsx | 258 ++++++++++-------- src/components/ImportSummaries/Codegen.tsx | 2 +- src/components/ImportSummaries/User.tsx | 2 +- .../Imports/StudentClassroomTransfer.tsx | 173 ++++++++---- src/pages/classrooms/index.tsx | 2 +- 5 files changed, 264 insertions(+), 173 deletions(-) diff --git a/src/components/ImportSummaries/Classroom.tsx b/src/components/ImportSummaries/Classroom.tsx index d0386412..215d7d53 100644 --- a/src/components/ImportSummaries/Classroom.tsx +++ b/src/components/ImportSummaries/Classroom.tsx @@ -14,19 +14,28 @@ import UserTable from '../Tables/UserTable'; import ParseExcelErrors from './ExcelError'; import { errorsByRows } from '@/utils/excel.errors'; import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer'; -import { ExcelUserDuplicatesMap } from './User'; -const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ state }) => { +const ClassroomImportSummary: React.FC<{ state: ClassroomTransferState }> = ({ state }) => { const [showErrorsModal, setShowErrorsModal] = useState(false); const [showDuplicatesModal, setShowDuplicatesModal] = useState(false); const [showNotFoundModal, setShowNotFoundModal] = useState(false); const [showOtherEntityModal, setShowOtherEntityModal] = useState(false); const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false); const [showNotOwnedModal, setShowNotOwnedModal] = useState(false); + const [showMismatchesModal, setShowMismatchesModal] = useState(false); - const errorCount = state.parsedExcel?.errors ? + const errorCount = state.parsedExcel?.errors ? Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0; + const fieldMapper: { [key: string]: string } = { + "firstName": "First Name", + "lastName": "Last Name", + "studentID": "Student ID", + "phone": "Phone Number", + "passport_id": "Passport/National ID", + "country": "Country" + } + return ( <> @@ -120,11 +129,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta )} - {state.duplicatedRows && state.duplicatedRows.count > 0 && ( + {state.duplicatedRows.length > 0 && (
- {state.duplicatedRows.count} duplicate entries in file + {state.duplicatedRows.length} duplicate entries in file
)} + {state.userMismatches.length > 0 && ( +
+
+ + {state.userMismatches.length} users with mismatched information +
+ +
+ )} + {errorCount > 0 && (
@@ -151,73 +175,79 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta )}
- {((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 || - state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || - state.notOwnedClassrooms.length > 0) && ( -
-
-
- -
-
-

- The following will be excluded from transfer: -

-
    - {state.notFoundUsers.length > 0 && ( -
  • -
    - {state.notFoundUsers.length} users not found -
    -
  • - )} - {state.otherEntityUsers.length > 0 && ( -
  • -
    - {state.otherEntityUsers.length} users from different entities -
    -
  • - )} - {state.notOwnedClassrooms.length > 0 && ( -
  • -
    - {state.notOwnedClassrooms.length} classrooms not owned: -
    - {state.notOwnedClassrooms.join(', ')} + {(state.duplicatedRows.length > 0 || state.userMismatches.length > 0 || errorCount > 0 || + state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || + state.notOwnedClassrooms.length > 0) && ( +
    +
    +
    + +
    +
    +

    + The following will be excluded from transfer: +

    +
      + {state.notFoundUsers.length > 0 && ( +
    • +
      + {state.notFoundUsers.length} users not found
      -
    -
  • - )} - {state.duplicatedRows && state.duplicatedRows.count > 0 && ( -
  • -
    - {state.duplicatedRows.count} duplicate entries -
    -
  • - )} - {state.alreadyInClass && state.alreadyInClass.length > 0 && ( -
  • -
    - {state.alreadyInClass.length} users that are already assigned to the classroom -
    -
  • - )} - {errorCount > 0 && ( -
  • -
    - {errorCount} rows with invalid information -
    -
  • - )} -
+ + )} + {state.otherEntityUsers.length > 0 && ( +
  • +
    + {state.otherEntityUsers.length} users from different entities +
    +
  • + )} + {state.notOwnedClassrooms.length > 0 && ( +
  • +
    + {state.notOwnedClassrooms.length} classrooms not owned: +
    + {state.notOwnedClassrooms.join(', ')} +
    +
    +
  • + )} + {state.duplicatedRows.length > 0 && ( +
  • +
    + {state.duplicatedRows.length} duplicate entries +
    +
  • + )} + + {state.userMismatches.length > 0 && ( +
  • +
    + {state.userMismatches.length} users with mismatched information +
    +
  • + )} + {state.alreadyInClass && state.alreadyInClass.length > 0 && ( +
  • +
    + {state.alreadyInClass.length} users that are already assigned to the classroom +
    +
  • + )} + {errorCount > 0 && ( +
  • +
    + {errorCount} rows with invalid information +
    +
  • + )} + +
    -
    - )} + )}
    - - {/* Modals */} setShowErrorsModal(false)}> <>
    @@ -283,53 +313,59 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta

    Duplicate Entries

    - {state.duplicatedRows && ( -
    - {(Object.keys(state.duplicatedRows.duplicates) as Array).map(field => { - const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries()) - .filter((entry): entry is [string, number[]] => entry[1].length > 1); - - if (duplicates.length === 0) return null; - - return ( -
    -
    -

    - {field} duplicates -

    - - {duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'} - +
    + {state.duplicatedRows.map((duplicate) => ( +
    +
    +
    + Row {duplicate.rowNumber} +
    + Email: {duplicate.email}
    - -
    - {duplicates.map(([value, rows]) => ( -
    -
    -
    - {value} -
    - Appears in rows: - - {rows.join(', ')} - -
    -
    - - {rows.length} occurrences - -
    -
    - ))} +
    + Classroom: {duplicate.classroom}
    - ); - })} -
    - )} +
    +
    + ))} +
    + + + setShowMismatchesModal(false)}> + <> +
    + +

    Mismatched User Information

    +
    +
    + {state.userMismatches.map((mismatch) => ( +
    +
    + Email: {mismatch.email} +
    +
    + Rows: {mismatch.rows.join(', ')} +
    +
    + {mismatch.mismatches.map((field) => ( +
    +
    {fieldMapper[field.field]}:
    +
    + Values: {field.values.join(', ')} +
    +
    + ))} +
    +
    + ))} +
    diff --git a/src/components/ImportSummaries/Codegen.tsx b/src/components/ImportSummaries/Codegen.tsx index 3e531d30..a48068d2 100644 --- a/src/components/ImportSummaries/Codegen.tsx +++ b/src/components/ImportSummaries/Codegen.tsx @@ -116,7 +116,7 @@ const CodeGenImportSummary: React.FC = ({ infos, parsedExcel, duplicateRo if (duplicates.length === 0) return null; return (
    - {field}: rows { + {fieldMapper[field]}: rows { duplicates.map(([_, rows]) => rows.join(', ')).join('; ') }
    diff --git a/src/components/ImportSummaries/User.tsx b/src/components/ImportSummaries/User.tsx index c89e3f4a..2e15b331 100644 --- a/src/components/ImportSummaries/User.tsx +++ b/src/components/ImportSummaries/User.tsx @@ -162,7 +162,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse if (duplicates.length === 0) return null; return (
    - {field}: rows { + {fieldMapper[field]}: rows { duplicates.map(([_, rows]) => rows.join(', ')).join('; ') }
    diff --git a/src/components/Imports/StudentClassroomTransfer.tsx b/src/components/Imports/StudentClassroomTransfer.tsx index bc6f2dc6..ae7d127c 100644 --- a/src/components/Imports/StudentClassroomTransfer.tsx +++ b/src/components/Imports/StudentClassroomTransfer.tsx @@ -22,10 +22,26 @@ import { EntityWithRoles } from "@/interfaces/entity"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); +export interface DuplicateClassroom { + rowNumber: number; + email: string; + classroom: string; +} + +export interface Mismatches { + email: string; + rows: number[]; + mismatches: { + field: string; + values: any[]; + }[]; +} + export interface ClassroomTransferState { stage: number; parsedExcel: { rows?: any[]; errors?: any[] } | undefined; - duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined; + duplicatedRows: DuplicateClassroom[]; + userMismatches: Mismatches[]; imports: UserImport[]; notFoundUsers: UserImport[]; otherEntityUsers: UserImport[]; @@ -43,7 +59,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole const [classroomTransferState, setClassroomTransferState] = useState({ stage: 0, parsedExcel: undefined, - duplicatedRows: undefined, + duplicatedRows: [], + userMismatches: [], imports: [], notFoundUsers: [], otherEntityUsers: [], @@ -169,79 +186,116 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole }, [filesContent]); // Stage 1 - Excel Parsing - // - Find duplicate rows + // - Group rows by emails + // - Find duplicate rows (email + classroom) + // - Find email and other fields mismatch except classroom // - Parsing errors // - Set the data useEffect(() => { if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) { - const duplicates: ExcelUserDuplicatesMap = { - studentID: new Map(), - email: new Map(), - passport_id: new Map(), - phone: new Map() - }; - const duplicateValues = new Set(); - const duplicateRowIndices = new Set(); - const errorRowIndices = new Set( classroomTransferState.parsedExcel.errors?.map(error => error.row) || [] ); - + + const emailClassroomMap = new Map>(); // email -> (group -> row numbers) + const emailRowMap = new Map>(); // email -> (row number -> row data) + + // Group rows by email and assign row data classroomTransferState.parsedExcel.rows.forEach((row, index) => { - if (!errorRowIndices.has(index + 2)) { - (Object.keys(duplicates) as Array).forEach(field => { - if (row !== null) { - const value = row[field]; - if (value) { - if (!duplicates[field].has(value)) { - duplicates[field].set(value, [index + 2]); - } else { - const existingRows = duplicates[field].get(value); - if (existingRows) { - existingRows.push(index + 2); - duplicateValues.add(value); - existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); - } - } - } - } - }); + const rowNum = index + 2; + if (!errorRowIndices.has(rowNum) && row !== null) { + const email = row.email.toString().trim().toLowerCase(); + const classroom = row.group; + + if (!emailRowMap.has(email)) { + emailRowMap.set(email, new Map()); + } + emailRowMap.get(email)!.set(rowNum, row); + + if (!emailClassroomMap.has(email)) { + emailClassroomMap.set(email, new Map()); + } + const classroomMap = emailClassroomMap.get(email)!; + if (!classroomMap.has(classroom)) { + classroomMap.set(classroom, []); + } + classroomMap.get(classroom)!.push(rowNum); } }); - - const infos = classroomTransferState.parsedExcel.rows - .map((row, index) => { - if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { - return undefined; + + const duplicatedRows: DuplicateClassroom[] = []; + const userMismatches: Mismatches[] = []; + const validRows = []; + + for (const [email, classroomMap] of emailClassroomMap) { + const rowDataMap = emailRowMap.get(email)!; + const allRowsForEmail = Array.from(rowDataMap.keys()); + + // Check for duplicates (same email + classroom) + for (const [classroom, rowNumbers] of classroomMap) { + if (rowNumbers.length > 1) { + rowNumbers.forEach(row => duplicatedRows.push({ + rowNumber: row, + email, + classroom + })); + } else { + validRows.push(rowNumbers[0]); } - const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; - if (!email || !EMAIL_REGEX.test(email.toString().trim())) { - return undefined; + } + + // Check for mismatches in other fields + if (allRowsForEmail.length > 1) { + const fields = ['firstName', 'lastName', 'studentID', 'passport_id', 'phone']; + const mismatches: {field: string; values: any[]}[] = []; + + fields.forEach(field => { + const uniqueValues = new Set( + allRowsForEmail.map(rowNum => rowDataMap.get(rowNum)![field]) + ); + if (uniqueValues.size > 1) { + mismatches.push({ + field, + values: Array.from(uniqueValues) + }); + } + }); + + if (mismatches.length > 0) { + userMismatches.push({ + email, + rows: allRowsForEmail, + mismatches + }); } - - return { - email: email.toString().trim().toLowerCase(), - name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), - passport_id: passport_id?.toString().trim() || undefined, - groupName: group, - studentID, - demographicInformation: { - country: country?.countryCode, - passport_id: passport_id?.toString().trim() || undefined, - phone: phone.toString(), - }, - entity: undefined, - type: undefined - } as UserImport; - }) - .filter((item): item is UserImport => item !== undefined); + } + } + + const imports = validRows + .map(rowNum => classroomTransferState.parsedExcel!.rows![rowNum - 2]) + .filter((row): row is any => row !== null) + .map(row => ({ + email: row.email.toString().trim().toLowerCase(), + name: `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim(), + passport_id: row.passport_id?.toString().trim() || undefined, + groupName: row.group, + studentID: row.studentID, + demographicInformation: { + country: row.country?.countryCode, + passport_id: row.passport_id?.toString().trim() || undefined, + phone: row.phone.toString(), + }, + entity: undefined, + type: undefined + } as UserImport)); // On import reset state except excel parsing setClassroomTransferState((prev) => ({ ...prev, stage: 1, - duplicatedRows: { duplicates, count: duplicateRowIndices.size }, - imports: infos, + duplicatedRows, + userMismatches, + imports, notFoundUsers: [], otherEntityUsers: [], notOwnedClassrooms: [], @@ -342,7 +396,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole setClassroomTransferState({ stage: 0, parsedExcel: undefined, - duplicatedRows: undefined, + duplicatedRows: [], + userMismatches: [], imports: [], notFoundUsers: [], otherEntityUsers: [], diff --git a/src/pages/classrooms/index.tsx b/src/pages/classrooms/index.tsx index 958450c4..293ac3d1 100644 --- a/src/pages/classrooms/index.tsx +++ b/src/pages/classrooms/index.tsx @@ -124,7 +124,7 @@ export default function Home({ user, groups, entities }: Props) {
    - setShowImport(false)}> + setShowImport(false)} maxWidth="max-w-[85%]"> setShowImport(false)} />
    From bc89f4b9ce464676b123daf939d4e389b812b266 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 5 Jan 2025 22:35:57 +0000 Subject: [PATCH 4/4] ENCOA-310 --- .../Imports/StudentClassroomTransfer.tsx | 75 ++++++++++++------- src/pages/api/groups/controller.ts | 35 ++++++++- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/components/Imports/StudentClassroomTransfer.tsx b/src/components/Imports/StudentClassroomTransfer.tsx index ae7d127c..dcbb1157 100644 --- a/src/components/Imports/StudentClassroomTransfer.tsx +++ b/src/components/Imports/StudentClassroomTransfer.tsx @@ -18,6 +18,7 @@ import UserTable from "../Tables/UserTable"; import ClassroomImportSummary from "../ImportSummaries/Classroom"; import Select from "../Low/Select"; import { EntityWithRoles } from "@/interfaces/entity"; +import { useRouter } from "next/router"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -47,7 +48,7 @@ export interface ClassroomTransferState { otherEntityUsers: UserImport[]; alreadyInClass: UserImport[]; notOwnedClassrooms: string[]; - classroomsToCreate: string[]; + validClassrooms: string[]; } @@ -66,9 +67,11 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole otherEntityUsers: [], alreadyInClass: [], notOwnedClassrooms: [], - classroomsToCreate: [] + validClassrooms: [] }) + const router = useRouter(); + const { openFilePicker, filesContent, clear } = useFilePicker({ accept: ".xlsx", multiple: false, @@ -183,7 +186,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filesContent]); + }, [filesContent, entity]); // Stage 1 - Excel Parsing // - Group rows by emails @@ -299,10 +302,10 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole notFoundUsers: [], otherEntityUsers: [], notOwnedClassrooms: [], - classroomsToCreate: [], + validClassrooms: [], })); } - }, [classroomTransferState.parsedExcel]); + }, [classroomTransferState.parsedExcel, entity]); // Stage 2 - Student Filter // - Filter non existant students @@ -377,7 +380,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole ...prev, stage: 3, notOwnedClassrooms: notOwnedClassroomsSameName, - classroomsToCreate: Array.from(classrooms).filter( + validClassrooms: Array.from(classrooms).filter( (name) => !new Set(notOwnedClassroomsSameName).has(name) ) })) @@ -388,7 +391,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) { crossRefClassrooms(); } - }, [classroomTransferState.imports, classroomTransferState.stage, user.id]) + }, [classroomTransferState.imports, classroomTransferState.stage, user.id, entity]) const clearAndReset = () => { @@ -403,7 +406,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole otherEntityUsers: [], alreadyInClass: [], notOwnedClassrooms: [], - classroomsToCreate: [] + validClassrooms: [] }); clear(); }; @@ -422,7 +425,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole try { setIsLoading(true); - const getIds = async () => { try { const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", { @@ -445,7 +447,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole if (!imports) return; - await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", { ids: imports.map((u) => u.id), entity @@ -456,6 +457,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole entity }); + const { data: existingGroupsMap } = await axios.post("/api/groups/controller?op=existantGroupIds", { + names: classroomTransferState.validClassrooms + }); const groupedUsers = imports.reduce((acc, user) => { if (!acc[user.groupName]) { @@ -464,12 +468,18 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole acc[user.groupName].push(user); return acc; }, {} as Record); - + + // ############## + // # New Groups # + // ############## const newGroupUsers = Object.fromEntries( Object.entries(groupedUsers) - .filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName)) + .filter(([groupName]) => + classroomTransferState.validClassrooms.includes(groupName) && + !existingGroupsMap[groupName] + ) ); - + const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => { const groupData: Partial = { admin: user.id, @@ -479,25 +489,33 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole }; return axios.post('/api/groups', groupData); }); - - - const existingGroupUsers = Object.fromEntries( + + // ################### + // # Existant Groups # + // ################### + const allExistingUsers = Object.fromEntries( Object.entries(groupedUsers) - .filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName)) + .filter(([groupName]) => + !classroomTransferState.validClassrooms.includes(groupName) || + existingGroupsMap[groupName] + ) ); - + let updatePromises: Promise[] = []; + + if (Object.keys(allExistingUsers).length > 0) { + const { data: { groups: groupNameToId, users: userEmailToId } } = await axios.post('/api/groups/controller?op=getIds', { + names: Object.keys(allExistingUsers), + userEmails: Object.values(allExistingUsers).flat().map(user => user.email) + }); - if (Object.keys(existingGroupUsers).length > 0) { - const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { - names: Object.keys(existingGroupUsers), - userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) - }).then(response => response.data); - - const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => { - acc[groupNameToId[groupName]] = users; + const existingGroupsWithIds = Object.entries(allExistingUsers).reduce((acc, [groupName, users]) => { + const groupId = groupNameToId[groupName]; + if (groupId) { + acc[groupId] = users; + } return acc; - }, {} as Record); + }, {} as Record); updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { const userIds = users.map(user => userEmailToId[user.email]); @@ -506,13 +524,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole }); }); } - + await Promise.all([ ...createGroupPromises, ...updatePromises ]); toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`); + router.replace("/classrooms"); onFinish(); } catch (error) { console.error(error); diff --git a/src/pages/api/groups/controller.ts b/src/pages/api/groups/controller.ts index 73a8ed6c..6fe67631 100644 --- a/src/pages/api/groups/controller.ts +++ b/src/pages/api/groups/controller.ts @@ -19,6 +19,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } else if (req.method === 'POST') { switch (op) { + case 'existantGroupIds': + res.status(200).json(await existantGroupIds(req.body.names)); + break; case 'crossRefOwnership': res.status(200).json(await crossRefOwnership(req.body)); break; @@ -41,7 +44,7 @@ async function crossRefOwnership(body: any): Promise { const { userId, classrooms, entity } = body; const existingClassrooms = await db.collection('groups') - .find({ + .find({ name: { $in: classrooms }, admin: { $ne: userId } }) @@ -61,7 +64,7 @@ async function crossRefOwnership(body: any): Promise { const adminEntitiesMap = new Map( adminUsers.map(admin => [ - admin.id, + admin.id, admin.entities?.map((e: any) => e.id) || [] ]) ); @@ -104,7 +107,6 @@ async function getIds(body: any): Promise> { async function deletePriorEntitiesGroups(body: any) { const { ids, entity } = body; - if (!Array.isArray(ids) || ids.length === 0 || !entity) { return; } @@ -123,8 +125,33 @@ async function deletePriorEntitiesGroups(body: any) { return; } - db.collection('groups').updateMany( + const affectedGroups = await db.collection('groups') + .find({ participants: { $in: toDeleteUserIds } }) + .project({ id: 1, _id: 0 }) + .toArray(); + + await db.collection('groups').updateMany( { participants: { $in: toDeleteUserIds } }, { $pull: { participants: { $in: toDeleteUserIds } } } as any ); + + // delete groups that were updated and have no participants + await db.collection('groups').deleteMany({ + id: { $in: affectedGroups.map(g => g.id) }, + participants: { $size: 0 } + }); +} + +async function existantGroupIds(names: string[]) { + const existingGroups = await db.collection('groups') + .find({ + name: { $in: names } + }) + .project({ id: 1, name: 1, _id: 0 }) + .toArray(); + + return existingGroups.reduce((acc, group) => { + acc[group.name] = group.id; + return acc; + }, {} as Record); }