diff --git a/src/components/ExamEditor/ImportExam/Templates.tsx b/src/components/ExamEditor/ImportExam/Templates.tsx index d836262a..f7c90fff 100644 --- a/src/components/ExamEditor/ImportExam/Templates.tsx +++ b/src/components/ExamEditor/ImportExam/Templates.tsx @@ -155,11 +155,11 @@ const Templates: React.FC = ({ module, state, setState }) => { {["reading", "level"].includes(module) && (
  • - a part must only contain a reading passage and it must be between the part delineator (e.g. Part 1) and the part exercises. + a part must only contain a single reading passage and it must be between the part delineator (e.g. Part 1) and the part exercises.
  • )}
  • - if solutions are going to be uploaded the exercise numbers/id's must match the ones in the solutions. + if solutions are going to be uploaded, the exercise numbers/id's must match the ones in the solutions.
  • @@ -186,7 +186,7 @@ const Templates: React.FC = ({ module, state, setState }) => { }

    - {`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling and formatting but it must adhere to the previous requirements${state.type === "exam" ? "and exercises of the same type should have the same formatting" : ""}.`} + {`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling and formatting but it must adhere to the previous requirements${state.type === "exam" ? " and exercises of the same type should have the same formatting" : ""}.`}

    diff --git a/src/components/ExamEditor/ResetModule.tsx b/src/components/ExamEditor/ResetModule.tsx new file mode 100644 index 00000000..b763f664 --- /dev/null +++ b/src/components/ExamEditor/ResetModule.tsx @@ -0,0 +1,116 @@ +import Button from "@/components/Low/Button"; +import { Module } from "@/interfaces"; +import useExamEditorStore from "@/stores/examEditor"; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; +import { capitalize } from "lodash"; +import React, { Fragment, useCallback, useEffect, useState } from "react"; + +interface Props { + module: Module; + isOpen: boolean; + setIsOpen: React.Dispatch>; + setNumberOfLevelParts: React.Dispatch>; +} + +const ResetModule: React.FC = ({ module, isOpen, setIsOpen, setNumberOfLevelParts }) => { + const [isClosing, setIsClosing] = useState(false); + const [mounted, setMounted] = useState(false); + const { dispatch } = useExamEditorStore(); + + useEffect(() => { + if (isOpen) { + setMounted(true); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen && mounted) { + const timer = setTimeout(() => { + setMounted(false); + setIsClosing(false); + }, 300); + return () => clearTimeout(timer); + } + }, [isOpen, mounted]); + + const blockMultipleClicksClose = useCallback(() => { + if (isClosing) return; + setIsClosing(true); + setIsOpen(!isOpen); + + const timer = setTimeout(() => { + setIsClosing(false); + }, 300); + + return () => clearTimeout(timer); + }, [isClosing, setIsOpen, isOpen]); + + if (!mounted && !isOpen) return null; + + const handleResetModule = () => { + dispatch({ type: 'RESET_MODULE', payload: { module } }); + setIsOpen(false); + setNumberOfLevelParts(1); + } + + return ( + setIsClosing(false)} + beforeLeave={() => setIsClosing(true)} + afterLeave={() => { + setIsClosing(false); + setMounted(false); + }} + > + blockMultipleClicksClose()} className="relative z-50"> + +
    + + + +
    + + Reset {capitalize(module)} Module +
    +
    +

    + Do you want to reset the {module} module? +

    +
    +

    This will reset all the current data in the {module} module and cannot be undone.

    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + ); +} + +export default ResetModule; diff --git a/src/components/ExamEditor/SettingsEditor/index.tsx b/src/components/ExamEditor/SettingsEditor/index.tsx index fc9da308..306a578b 100644 --- a/src/components/ExamEditor/SettingsEditor/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/index.tsx @@ -34,7 +34,9 @@ const SettingsEditor: React.FC = ({ canPreview, canSubmit }) => { + const { dispatch } = useExamEditorStore() const examLabel = useExamEditorStore((state) => state.modules[module].examLabel) || ''; + const type = useExamEditorStore((s) => s.modules[module].type); const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( module, sectionId @@ -50,6 +52,18 @@ const SettingsEditor: React.FC = ({ updateLocalAndScheduleGlobal({ category: text }); }, [updateLocalAndScheduleGlobal]); + const typeOptions = [ + { value: 'general', label: 'General' }, + { value: 'academic', label: 'Academic' } + ]; + + const onTypeChange = useCallback((option: { value: string | null, label: string }) => { + dispatch({ + type: 'UPDATE_MODULE', + payload: { module, updates: { type: option.value as "academic" | "general" | undefined } } + }); + }, [dispatch, module]); + const onIntroOptionChange = useCallback((option: { value: string | null, label: string }) => { let updates: Partial = { introOption: option }; @@ -79,7 +93,7 @@ const SettingsEditor: React.FC = ({ currentIntro: text }); }, [updateLocalAndScheduleGlobal]); - + return (
    {sectionLabel} Settings
    @@ -100,6 +114,18 @@ const SettingsEditor: React.FC = ({ value={localSettings.category || ''} /> + {["reading", "writing"].includes(module) && updateLocalAndScheduleGlobal({ isTypeDropdownOpen: isOpen }, false)} + > + = ({ levelParts = 0 }) => { value={examLabel} required /> +
    + {["reading", "listening", "level"].includes(currentModule) && }
    diff --git a/src/components/Low/Button.tsx b/src/components/Low/Button.tsx index 32b1d708..0f4f4d5d 100644 --- a/src/components/Low/Button.tsx +++ b/src/components/Low/Button.tsx @@ -12,6 +12,7 @@ interface Props { padding?: string; onClick?: () => void; type?: "button" | "reset" | "submit"; + customColor?: string; } export default function Button({ @@ -19,6 +20,7 @@ export default function Button({ variant = "solid", disabled = false, isLoading = false, + customColor = undefined, className, children, type, @@ -65,7 +67,7 @@ export default function Button({ className={clsx( "rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none", padding, - colorClassNames[color][variant], + customColor ? customColor : colorClassNames[color][variant], className, )} disabled={disabled || isLoading}> diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 0c3b9329..5273c849 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -97,6 +97,7 @@ export interface WritingExam extends ExamBase { module: "writing"; enableNavigation?: boolean; exercises: WritingExercise[]; + type?: "academic" | "general"; } interface WordCounter { diff --git a/src/pages/(admin)/Lists/BatchCreateUser.tsx b/src/pages/(admin)/Lists/BatchCreateUser.tsx index f753c105..a2c46f02 100644 --- a/src/pages/(admin)/Lists/BatchCreateUser.tsx +++ b/src/pages/(admin)/Lists/BatchCreateUser.tsx @@ -1,6 +1,6 @@ import Button from "@/components/Low/Button"; import axios from "axios"; -import { uniqBy } from "lodash"; +import { capitalize, uniqBy } from "lodash"; import { useEffect, useState } from "react"; import { toast } from "react-toastify"; import { useFilePicker } from "use-file-picker"; @@ -19,6 +19,9 @@ import { Type, UserImport } from "../../../interfaces/IUserImport"; import UserTable from "../../../components/UserTable"; import { EntityWithRoles } from "@/interfaces/entity"; import Select from "@/components/Low/Select"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { FaFileDownload } from "react-icons/fa"; +import { HiOutlineDocumentText } from "react-icons/hi"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -135,7 +138,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi } setInfos(information); - } catch(e) { + } catch (e) { console.log(e) toast.error( @@ -157,7 +160,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi const crossRefEmails = response.data; if (!!crossRefEmails) { - const existingEmails = new Set(crossRefEmails.map((x: any)=> x.email)); + const existingEmails = new Set(crossRefEmails.map((x: any) => x.email)); const dupes = infos.filter(info => existingEmails.has(info.email)); const newUsersList = infos.filter(info => !existingEmails.has(info.email)); setNewUsers(newUsersList); @@ -192,10 +195,10 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi setIsLoading(true); try { - await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))}); + await axios.post("/api/batch_users", { users: newUsers.map((user) => ({ ...user, type, expiryDate })) }); toast.success(`Successfully added ${newUsers.length} user(s)!`); onFinish(); - } catch(e) { + } catch (e) { console.error(e) toast.error("Something went wrong, please try again later!"); } finally { @@ -210,57 +213,126 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi } }; + + const handleTemplateDownload = () => { + const fileName = "UsersTemplate.xlsx"; + const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`; + + const link = document.createElement('a'); + link.href = url; + + link.download = fileName; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + return ( <> - setShowHelp(false)} title="Excel File Format"> -
    - Please upload an Excel file with the following format: - - - - - - - - - - - - - -
    First NameLast NameStudent IDPassport/National IDE-mailPhone NumberClassroom NameCountry
    - - Notes: -
      -
    • - All incorrect e-mails will be ignored;
    • -
    • - All already registered e-mails will be ignored;
    • -
    • - You may have a header row with the format above, however, it is not necessary;
    • -
    • - All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
    • -
    -
    -
    + setShowHelp(false)}> + <> +
    Excel File Format
    +
    +
    +
    + +

    + The uploaded document must: +

    +
    +
      +
    • + be an Excel .xlsx document. +
    • +
    • + only have a single spreadsheet with only the following 8 columns in the same order: +
      + + + + + + + + + + + + + +
      First NameLast NameStudent IDPassport/National IDE-mailPhone NumberClassroom NameCountry
      +
      +
    • +
    +
    +
    +
    + +

    + Note that: +

    +
    +
      +
    • + all incorrect e-mails will be ignored. +
    • +
    • + all already registered e-mails will be ignored. +
    • +
    • + the spreadsheet may have a header row with the format above, however, it is not necessary as long the columns are in the right order. +
    • +
    • + all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below. +
    • +
    +
    +
    +

    + {`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`} +

    +
    +
    + + + +
    +
    +
    -
    setShowHelp(true)}> - -
    +
    - - ({ value: e.id, label: e.label }))} + onChange={(e) => setEntity(e?.value || undefined)} + isClearable={checkAccess(user, ["admin", "developer"])} + />
    {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index d1ea2bd6..c1d66518 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -15,6 +15,7 @@ export const defaultSettings = (module: Module) => { isCategoryDropdownOpen: false, isIntroDropdownOpen: false, isExerciseDropdownOpen: false, + isTypeDropdownOpen: false, } switch (module) { @@ -163,6 +164,9 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => importing: false, edit: [], }; + if (["reading", "writing"].includes(module)) { + state["type"] = "general"; + } return state; } diff --git a/src/stores/examEditor/reducers/index.ts b/src/stores/examEditor/reducers/index.ts index e8f32f37..9f7479ae 100644 --- a/src/stores/examEditor/reducers/index.ts +++ b/src/stores/examEditor/reducers/index.ts @@ -7,15 +7,12 @@ import { Module } from "@/interfaces"; import { updateExamWithUserSolutions } from "@/stores/exam/utils"; import { defaultExamUserSolutions } from "@/utils/exams"; -type UpdateRoot = { - type: 'UPDATE_ROOT'; - payload: { - updates: Partial - } -}; -type RootActions = { type: 'FULL_RESET' } | { type: "INIT_EXAM_EDIT", payload: { exam: Exam; examModule: Module; id: string } }; +type RootActions = { type: 'FULL_RESET' } | +{ type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } | +{ type: 'UPDATE_ROOT'; payload: { updates: Partial } } | +{ type: 'RESET_MODULE'; payload: { module: Module } }; -export type Action = ModuleActions | SectionActions | UpdateRoot | RootActions; +export type Action = ModuleActions | SectionActions | RootActions; export const rootReducer = ( state: ExamEditorStore, @@ -55,6 +52,16 @@ export const rootReducer = ( ...state, ...updates }; + case 'RESET_MODULE': + const { module } = action.payload; + const timer = ["reading", "writing", "level"].includes(module) ? + 60 : (module === "speaking" ? 14 : 30); + return { + modules: { + ...state.modules, + [module]: defaultModuleSettings(module, timer), + }, + }; case 'FULL_RESET': return { title: "", diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index adf4d4c0..b2b1454a 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -16,6 +16,7 @@ export interface SectionSettings { currentIntro: string | undefined; isCategoryDropdownOpen: boolean; isIntroDropdownOpen: boolean; + isTypeDropdownOpen: boolean; } export interface SpeakingSectionSettings extends SectionSettings { @@ -114,6 +115,7 @@ export interface ModuleState { importModule: boolean; importing: boolean; edit: number[]; + type?: "general" | "academic"; } export interface Avatar {