Merged in addedAccess-bugfixes (pull request #150)
AddedAccess bugfixes Approved-by: Tiago Ribeiro
This commit is contained in:
51
scripts/updatePrivateFieldExams.js
Normal file
51
scripts/updatePrivateFieldExams.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { MongoClient } from "mongodb";
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
|
||||
async function migrateData() {
|
||||
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
|
||||
const client = new MongoClient(uri, options);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to MongoDB");
|
||||
if (!process.env.MONGODB_DB) {
|
||||
throw new Error("Missing env var: MONGODB_DB");
|
||||
}
|
||||
const db = client.db(dbName);
|
||||
for (const string of MODULE_ARRAY) {
|
||||
const collection = db.collection(string);
|
||||
const result = await collection.updateMany(
|
||||
{ private: { $exists: false } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
const result2 = await collection.updateMany(
|
||||
{ private: true },
|
||||
{ $set: { access: "private" }, $unset: { private: "" } }
|
||||
);
|
||||
const result1 = await collection.updateMany(
|
||||
{ private: { $exists: true } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
console.log(
|
||||
`Updated ${
|
||||
result.modifiedCount + result1.modifiedCount
|
||||
} documents to "access: public" in ${string}`
|
||||
);
|
||||
console.log(
|
||||
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
|
||||
);
|
||||
}
|
||||
console.log("Migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed.");
|
||||
}
|
||||
}
|
||||
//migrateData(); // uncomment to run the migration
|
||||
@@ -1,17 +1,12 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import { useState} from "react";
|
||||
import { BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
@@ -38,7 +38,7 @@ const LevelSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||
@@ -200,7 +200,7 @@ const LevelSettings: React.FC = () => {
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/level', exam);
|
||||
@@ -243,7 +243,7 @@ const LevelSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as LevelExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -27,7 +27,7 @@ const ListeningSettings: React.FC = () => {
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
access,
|
||||
instructionsState
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
@@ -144,7 +144,7 @@ const ListeningSettings: React.FC = () => {
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsURL
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@ const ListeningSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
instructions: instructionsState.currentInstructionsURL
|
||||
} as ListeningExam);
|
||||
setExerciseIndex(0);
|
||||
|
||||
@@ -15,135 +15,137 @@ import ReadingComponents from "./components";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
type,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, access, type } =
|
||||
useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection
|
||||
);
|
||||
const { localSettings, updateLocalAndScheduleGlobal } =
|
||||
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
|
||||
const currentSection = sections.find(
|
||||
(section) => section.sectionId == focusedSection
|
||||
)?.state as ReadingPart;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Reading Passage 1",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.",
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value:
|
||||
"Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Reading Passage 1",
|
||||
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
||||
}
|
||||
];
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) =>
|
||||
(s.state as ReadingPart).exercises &&
|
||||
(s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const submitReading = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
const submitReading = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category,
|
||||
};
|
||||
}),
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
access,
|
||||
type: type!,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
}
|
||||
axios
|
||||
.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
// Successfully submitted exam
|
||||
if (result.status === 200) {
|
||||
toast.success(result.data.message);
|
||||
} else if (result.status === 207) {
|
||||
toast.warning(result.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(
|
||||
error.response.data.error ||
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
||||
}
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category,
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
access: access,
|
||||
type: type!,
|
||||
} as ReadingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Passage ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="reading"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingSettings;
|
||||
|
||||
@@ -30,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
@@ -185,7 +185,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
access,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
@@ -238,7 +238,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -23,7 +23,7 @@ const WritingSettings: React.FC = () => {
|
||||
const {
|
||||
minTimer,
|
||||
difficulty,
|
||||
isPrivate,
|
||||
access,
|
||||
sections,
|
||||
focusedSection,
|
||||
type,
|
||||
@@ -81,7 +81,7 @@ const WritingSettings: React.FC = () => {
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
});
|
||||
setExerciseIndex(0);
|
||||
@@ -134,7 +134,7 @@ const WritingSettings: React.FC = () => {
|
||||
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
access,
|
||||
type: type!
|
||||
};
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import SectionRenderer from "./SectionRenderer";
|
||||
import Checkbox from "../Low/Checkbox";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import {capitalize} from "lodash";
|
||||
import {Difficulty} from "@/interfaces/exam";
|
||||
import {useCallback, useEffect, useMemo, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {ModuleState, SectionState} from "@/stores/examEditor/types";
|
||||
import {Module} from "@/interfaces";
|
||||
import { capitalize } from "lodash";
|
||||
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import WritingSettings from "./SettingsEditor/writing";
|
||||
import ReadingSettings from "./SettingsEditor/reading";
|
||||
@@ -16,243 +16,286 @@ import LevelSettings from "./SettingsEditor/level";
|
||||
import ListeningSettings from "./SettingsEditor/listening";
|
||||
import SpeakingSettings from "./SettingsEditor/speaking";
|
||||
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
||||
import {defaultSectionSettings} from "@/stores/examEditor/defaults";
|
||||
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||
import Button from "../Low/Button";
|
||||
import ResetModule from "./Standalone/ResetModule";
|
||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({
|
||||
levelParts = 0,
|
||||
entitiesAllowEditPrivacy = [],
|
||||
}) => {
|
||||
const {currentModule, dispatch} = useExamEditorStore();
|
||||
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore(
|
||||
(state) => state.modules[currentModule],
|
||||
);
|
||||
const ExamEditor: React.FC<{
|
||||
levelParts?: number;
|
||||
entitiesAllowEditPrivacy: EntityWithRoles[];
|
||||
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
sections,
|
||||
minTimer,
|
||||
expandedSections,
|
||||
examLabel,
|
||||
access,
|
||||
difficulty,
|
||||
sectionLabels,
|
||||
importModule,
|
||||
} = useExamEditorStore((state) => state.modules[currentModule]);
|
||||
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
||||
levelParts !== 0 ? levelParts : 1
|
||||
);
|
||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({length: levelParts}).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
})),
|
||||
},
|
||||
module: "level",
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts]);
|
||||
// For exam edits
|
||||
useEffect(() => {
|
||||
if (levelParts !== 0) {
|
||||
setNumberOfLevelParts(levelParts);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
})),
|
||||
},
|
||||
module: "level",
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelParts]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
});
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const currentSections = sections;
|
||||
const currentLabels = sectionLabels;
|
||||
let updatedSections: SectionState[];
|
||||
let updatedLabels: any;
|
||||
if (
|
||||
(currentModule === "level" &&
|
||||
currentSections.length !== currentLabels.length) ||
|
||||
numberOfLevelParts !== currentSections.length
|
||||
) {
|
||||
const newSections = [...currentSections];
|
||||
const newLabels = [...currentLabels];
|
||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||
if (currentSections.length !== numberOfLevelParts)
|
||||
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||
newLabels.push({
|
||||
id: i + 1,
|
||||
label: `Part ${i + 1}`,
|
||||
});
|
||||
}
|
||||
updatedSections = newSections;
|
||||
updatedLabels = newLabels;
|
||||
} else if (numberOfLevelParts < currentSections.length) {
|
||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId));
|
||||
const updatedExpandedSections = expandedSections.filter((sectionId) =>
|
||||
updatedSections.some((section) => section.sectionId === sectionId)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections,
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
updates: {
|
||||
sections: updatedSections,
|
||||
sectionLabels: updatedLabels,
|
||||
expandedSections: updatedExpandedSections,
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numberOfLevelParts]);
|
||||
|
||||
const sectionIds = sections.map((section) => section.sectionId);
|
||||
const sectionIds = sections.map((section) => section.sectionId);
|
||||
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
dispatch({type: "UPDATE_MODULE", payload: {updates}});
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const updateModule = useCallback(
|
||||
(updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const toggleSection = (sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({type: "TOGGLE_SECTION", payload: {sectionId}});
|
||||
};
|
||||
const toggleSection = (sectionId: number) => {
|
||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||
toast.error("Include at least one section!");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
||||
};
|
||||
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||
reading: ReadingSettings,
|
||||
writing: WritingSettings,
|
||||
speaking: SpeakingSettings,
|
||||
listening: ListeningSettings,
|
||||
level: LevelSettings,
|
||||
};
|
||||
|
||||
const Settings = ModuleSettings[currentModule];
|
||||
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
const Settings = ModuleSettings[currentModule];
|
||||
const showImport =
|
||||
importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
|
||||
const updateLevelParts = (parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
};
|
||||
const updateLevelParts = (parts: number) => {
|
||||
setNumberOfLevelParts(parts);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} />
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<ResetModule
|
||||
module={currentModule}
|
||||
isOpen={isResetModuleOpen}
|
||||
setIsOpen={setIsResetModuleOpen}
|
||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-4 w-full items-center -xl:flex-col">
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : [];
|
||||
updateModule({difficulty: selectedDifficulties});
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? difficulty.map((d) => ({
|
||||
value: d,
|
||||
label: capitalize(d),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||
<div className="flex flex-col gap-3 -xl:w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
||||
<div className="flex flex-row gap-8">
|
||||
{sectionLabels.map(({id, label}) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "bg-white border-mti-gray-platinum",
|
||||
)}
|
||||
onClick={() => toggleSection(id)}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Parts"
|
||||
min={1}
|
||||
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||
value={numberOfLevelParts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||
<div className="h-6" />
|
||||
<Checkbox
|
||||
isChecked={isPrivate}
|
||||
onChange={(checked) => updateModule({isPrivate: checked})}
|
||||
disabled={entitiesAllowEditPrivacy.length === 0}>
|
||||
Privacy (Only available for Assignments)
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({examLabel: text})}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{showImport ? (
|
||||
<ImportOrStartFromScratch
|
||||
module={currentModule}
|
||||
setNumberOfLevelParts={updateLevelParts}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isResetModuleOpen && (
|
||||
<ResetModule
|
||||
module={currentModule}
|
||||
isOpen={isResetModuleOpen}
|
||||
setIsOpen={setIsResetModuleOpen}
|
||||
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-4 w-full items-center -xl:flex-col">
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Timer
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) =>
|
||||
updateModule({
|
||||
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||
})
|
||||
}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Difficulty
|
||||
</label>
|
||||
<Select
|
||||
isMulti={true}
|
||||
options={DIFFICULTIES.map((x) => ({
|
||||
value: x,
|
||||
label: capitalize(x),
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
const selectedDifficulties = values
|
||||
? values.map((v) => v.value as Difficulty)
|
||||
: [];
|
||||
updateModule({ difficulty: selectedDifficulties });
|
||||
}}
|
||||
value={
|
||||
difficulty
|
||||
? (Array.isArray(difficulty)
|
||||
? difficulty
|
||||
: [difficulty]
|
||||
).map((d) => ({
|
||||
value: d,
|
||||
label: capitalize(d),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||
<div className="flex flex-col gap-3 -xl:w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{sectionLabels[0].label.split(" ")[0]}
|
||||
</label>
|
||||
<div className="flex flex-row gap-8">
|
||||
{sectionLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
sectionIds.includes(id)
|
||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||
: "bg-white border-mti-gray-platinum"
|
||||
)}
|
||||
onClick={() => toggleSection(id)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Number of Parts
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Parts"
|
||||
min={1}
|
||||
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||
value={numberOfLevelParts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-64">
|
||||
<Select
|
||||
label="Access Type"
|
||||
options={ACCESSTYPE.map((item) => ({
|
||||
value: item,
|
||||
label: capitalize(item),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
if (value?.value) {
|
||||
updateModule({ access: value.value! as AccessType });
|
||||
}
|
||||
}}
|
||||
value={{ value: access, label: capitalize(access) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 flex-grow">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Exam Label *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Exam Label"
|
||||
name="label"
|
||||
onChange={(text) => updateModule({ examLabel: text })}
|
||||
roundness="xl"
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{currentModule === "listening" && <ListeningInstructions />}
|
||||
<Button
|
||||
onClick={() => setIsResetModuleOpen(true)}
|
||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||
className={`text-white self-end`}
|
||||
>
|
||||
Reset Module
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||
<Settings />
|
||||
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||
<SectionRenderer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamEditor;
|
||||
|
||||
@@ -149,10 +149,16 @@ export default function Table<T>({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading && (
|
||||
{isLoading ? (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
) : (
|
||||
rows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useExams() {
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>("/api/exam")
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>(`/api/exam`)
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, []);
|
||||
|
||||
return {exams, isLoading, isError, reload: getData};
|
||||
return { exams, isLoading, isError, reload: getData };
|
||||
}
|
||||
|
||||
@@ -1,60 +1,141 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import {useMemo, useState} from "react";
|
||||
import {BiChevronLeft} from "react-icons/bi";
|
||||
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BsChevronDoubleLeft,
|
||||
BsChevronDoubleRight,
|
||||
BsChevronLeft,
|
||||
BsChevronRight,
|
||||
} from "react-icons/bs";
|
||||
import Select from "../components/Low/Select";
|
||||
|
||||
export default function usePagination<T>(list: T[], size = 25) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(size);
|
||||
|
||||
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
|
||||
const items = useMemo(
|
||||
() => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage),
|
||||
[list, page, itemsPerPage]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (page * itemsPerPage >= list.length) setPage(0);
|
||||
}, [items, itemsPerPage, list.length, page]);
|
||||
|
||||
const render = () => (
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<span className="opacity-80">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const itemsPerPageOptions = [25, 50, 100, 200];
|
||||
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||
</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * size >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / size))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const render = () => (
|
||||
<div className="w-full flex gap-2 justify-between items-center">
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<Button
|
||||
className="w-[200px] h-fit"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-fit">
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: (itemsPerPage * page > items.length
|
||||
? items.length
|
||||
: itemsPerPage * page
|
||||
).toString(),
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setItemsPerPage(parseInt(value!.value ?? "25"))
|
||||
}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-[200px]"
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {page, items, setPage, render, renderMinimal};
|
||||
const renderMinimal = () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleLeft />
|
||||
</button>
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((prev) => prev - 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronLeft />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1 w-56">
|
||||
<Select
|
||||
value={{
|
||||
value: itemsPerPage.toString(),
|
||||
label: itemsPerPage.toString(),
|
||||
}}
|
||||
onChange={(value) => setItemsPerPage(parseInt(value!.value ?? "25"))}
|
||||
options={itemsPerPageOptions.map((size) => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
isClearable={false}
|
||||
styles={{
|
||||
control: (styles) => ({ ...styles, width: "100px" }),
|
||||
container: (styles) => ({ ...styles, width: "100px" }),
|
||||
}}
|
||||
/>
|
||||
<span className="opacity-80 w-32 text-center">
|
||||
{page * itemsPerPage + 1} -{" "}
|
||||
{itemsPerPage * (page + 1) > list.length
|
||||
? list.length
|
||||
: itemsPerPage * (page + 1)}
|
||||
/ {list.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronRight />
|
||||
</button>
|
||||
<button
|
||||
disabled={(page + 1) * itemsPerPage >= list.length}
|
||||
onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
|
||||
className="disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BsChevronDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { page, items, setPage, render, renderMinimal };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import instructions from "@/pages/api/exam/media/instructions";
|
||||
import { Module } from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
@@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels;
|
||||
// Left easy, medium and hard to support older exam versions
|
||||
export type BasicDifficulty = "easy" | "medium" | "hard";
|
||||
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
|
||||
export const ACCESSTYPE = ["public", "private", "confidential"] as const;
|
||||
export type AccessType = typeof ACCESSTYPE[number];
|
||||
|
||||
|
||||
|
||||
export interface ExamBase {
|
||||
@@ -24,7 +26,7 @@ export interface ExamBase {
|
||||
shuffle?: boolean;
|
||||
createdBy?: string; // option as it has been added later
|
||||
createdAt?: string; // option as it has been added later
|
||||
private?: boolean;
|
||||
access: AccessType;
|
||||
label?: string;
|
||||
}
|
||||
export interface ReadingExam extends ExamBase {
|
||||
|
||||
@@ -265,7 +265,7 @@ export default function CorporateGradingSystem({
|
||||
<>
|
||||
<Separator />
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Apply this grading system to other entities
|
||||
Copy this grading system to other entities
|
||||
</label>
|
||||
<Select
|
||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||
@@ -282,7 +282,7 @@ export default function CorporateGradingSystem({
|
||||
disabled={isLoading || otherEntities.length === 0}
|
||||
variant="outline"
|
||||
>
|
||||
Apply to {otherEntities.length} other entities
|
||||
Copy to {otherEntities.length} other entities
|
||||
</Button>
|
||||
<Separator />
|
||||
</>
|
||||
@@ -326,7 +326,7 @@ export default function CorporateGradingSystem({
|
||||
disabled={isLoading}
|
||||
className="mt-8"
|
||||
>
|
||||
Save Grading System
|
||||
Save Changes to entities
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,170 +15,210 @@ import { findBy, mapBy } from "@/utils";
|
||||
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
|
||||
import Table from "@/components/High/Table";
|
||||
|
||||
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
|
||||
type TableData = Code & { entity?: EntityWithRoles; creator?: User };
|
||||
const columnHelper = createColumnHelper<TableData>();
|
||||
|
||||
export default function CodeList({ user, entities, canDeleteCodes }
|
||||
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
export default function CodeList({
|
||||
user,
|
||||
entities,
|
||||
canDeleteCodes,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
canDeleteCodes?: boolean;
|
||||
}) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
|
||||
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
|
||||
const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
|
||||
const { users } = useUsers();
|
||||
const { codes, reload, isLoading } = useEntitiesCodes(
|
||||
isAdmin(user) ? undefined : entityIDs
|
||||
);
|
||||
|
||||
const data: TableData[] = useMemo(() => codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, 'id', code.entity),
|
||||
creator: findBy(users, 'id', code.creator)
|
||||
})) as TableData[], [codes, entities, users])
|
||||
const data: TableData[] = useMemo(
|
||||
() =>
|
||||
codes.map((code) => ({
|
||||
...code,
|
||||
entity: findBy(entities, "id", code.entity),
|
||||
creator: findBy(users, "id", code.creator),
|
||||
})) as TableData[],
|
||||
[codes, entities, users]
|
||||
);
|
||||
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
const toggleCode = (id: string) => {
|
||||
setSelectedCodes((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
// const toggleAllCodes = (checked: boolean) => {
|
||||
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
|
||||
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
// return setSelectedCodes([]);
|
||||
// };
|
||||
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
|
||||
const deleteCodes = async (codes: string[]) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
||||
)
|
||||
return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
const params = new URLSearchParams();
|
||||
codes.forEach((code) => params.append("code", code));
|
||||
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code?${params.toString()}`)
|
||||
.then(() => {
|
||||
toast.success(`Deleted the codes!`);
|
||||
setSelectedCodes([]);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
|
||||
const deleteCode = async (code: Code) => {
|
||||
if (!canDeleteCodes) return;
|
||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/code/${code.code}`)
|
||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this code!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => (""),
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "codeCheckbox",
|
||||
enableSorting: false,
|
||||
header: () => "",
|
||||
cell: (info) =>
|
||||
!info.row.original.userId ? (
|
||||
<Checkbox
|
||||
isChecked={selectedCodes.includes(info.getValue())}
|
||||
onChange={() => toggleCode(info.getValue())}
|
||||
>
|
||||
{""}
|
||||
</Checkbox>
|
||||
) : null,
|
||||
}),
|
||||
columnHelper.accessor("code", {
|
||||
header: "Code",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("creationDate", {
|
||||
header: "Creation Date",
|
||||
cell: (info) =>
|
||||
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: "E-mail",
|
||||
cell: (info) => info.getValue() || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("creator", {
|
||||
header: "Creator",
|
||||
cell: (info) =>
|
||||
info.getValue()
|
||||
? `${info.getValue().name} (${
|
||||
USER_TYPE_LABELS[info.getValue().type]
|
||||
})`
|
||||
: "N/A",
|
||||
}),
|
||||
columnHelper.accessor("entity", {
|
||||
header: "Entity",
|
||||
cell: (info) => info.getValue()?.label || "N/A",
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
header: "Availability",
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<span className="flex gap-1 items-center text-mti-green">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center text-mti-red">
|
||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Code } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{canDeleteCodes && !row.original.userId && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteCode(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-4 pt-1">
|
||||
{canDeleteCodes && (
|
||||
<div className="flex gap-4 items-center w-full justify-end">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedCodes.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteCodes(selectedCodes)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table<TableData>
|
||||
data={data}
|
||||
columns={defaultColumns}
|
||||
isLoading={isLoading}
|
||||
searchFields={[
|
||||
["code"],
|
||||
["email"],
|
||||
["entity", "label"],
|
||||
["creator", "name"],
|
||||
["creator", "type"],
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,287 +1,394 @@
|
||||
import {useMemo, useState} from "react";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import { useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {User} from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import {capitalize, uniq} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
import { capitalize } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import Modal from "@/components/Modal";
|
||||
import {checkAccess} from "@/utils/permissions";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {EntityWithRoles} from "@/interfaces/entity";
|
||||
import {BiEdit} from "react-icons/bi";
|
||||
import {findBy, mapBy} from "@/utils";
|
||||
import {getUserName} from "@/utils/users";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import { BiEdit } from "react-icons/bi";
|
||||
import { findBy, mapBy } from "@/utils";
|
||||
|
||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
const CLASSES: { [key in Module]: string } = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Exam>();
|
||||
|
||||
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
export default function ExamList({
|
||||
user,
|
||||
entities,
|
||||
}: {
|
||||
user: User;
|
||||
entities: EntityWithRoles[];
|
||||
}) {
|
||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||
|
||||
const {exams, reload} = useExams();
|
||||
const {users} = useUsers();
|
||||
const canViewConfidentialEntities = useMemo(
|
||||
() =>
|
||||
mapBy(
|
||||
findAllowedEntities(user, entities, "view_confidential_exams"),
|
||||
"id"
|
||||
),
|
||||
[user, entities]
|
||||
);
|
||||
|
||||
const filteredExams = useMemo(
|
||||
() =>
|
||||
exams.filter((e) => {
|
||||
if (!e.private) return true;
|
||||
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent));
|
||||
}),
|
||||
[exams, user?.entities],
|
||||
);
|
||||
const { exams, reload, isLoading } = useExams();
|
||||
const { users } = useUsers();
|
||||
// Pass this permission filter to the backend later
|
||||
const filteredExams = useMemo(
|
||||
() =>
|
||||
["admin", "developer"].includes(user?.type)
|
||||
? exams
|
||||
: exams.filter((item) => {
|
||||
if (
|
||||
item.access === "confidential" &&
|
||||
!canViewConfidentialEntities.find((x) =>
|
||||
(item.entities ?? []).includes(x)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[canViewConfidentialEntities, exams, user?.type]
|
||||
);
|
||||
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
const parsedExams = useMemo(() => {
|
||||
return filteredExams.map((exam) => {
|
||||
if (exam.createdBy) {
|
||||
const user = users.find((u) => u.id === exam.createdBy);
|
||||
if (!user) return exam;
|
||||
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...exam,
|
||||
createdBy: user.type === "developer" ? "system" : user.name,
|
||||
};
|
||||
}
|
||||
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
return exam;
|
||||
});
|
||||
}, [filteredExams, users]);
|
||||
|
||||
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
|
||||
searchFields,
|
||||
parsedExams
|
||||
);
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
const loadExam = async (module: Module, examId: string) => {
|
||||
const exam = await getExamById(module, examId.trim());
|
||||
if (!exam) {
|
||||
toast.error(
|
||||
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
||||
{
|
||||
toastId: "invalid-exam-id",
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}});
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: "INIT_EXAM",
|
||||
payload: { exams: [exam], modules: [module] },
|
||||
});
|
||||
|
||||
router.push("/exam");
|
||||
};
|
||||
router.push("/exam");
|
||||
};
|
||||
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||
/*
|
||||
const privatizeExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
|
||||
exam.access
|
||||
}?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to update this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
*/
|
||||
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||
const deleteExam = async (exam: Exam) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Exam not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||
}
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (
|
||||
exam.module === "reading" ||
|
||||
exam.module === "listening" ||
|
||||
exam.module === "level"
|
||||
) {
|
||||
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
|
||||
}
|
||||
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
return countExercises(exam.exercises);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("private", {
|
||||
header: "Private",
|
||||
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("module", {
|
||||
header: "Module",
|
||||
cell: (info) => (
|
||||
<span className={CLASSES[info.getValue()]}>
|
||||
{capitalize(info.getValue())}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||
header: "Exercises",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("minTimer", {
|
||||
header: "Timer",
|
||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||
}),
|
||||
columnHelper.accessor("access", {
|
||||
header: "Access",
|
||||
cell: (info) => <span>{capitalize(info.getValue())}</span>,
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (value) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Exam}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
<button
|
||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||
onClick={async () => await privatizeExam(row.original)}
|
||||
className="cursor-pointer tooltip">
|
||||
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||
</button>
|
||||
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdBy", {
|
||||
header: "Created By",
|
||||
cell: (info) =>
|
||||
!info.getValue()
|
||||
? "System"
|
||||
: findBy(users, "id", info.getValue())?.name || "N/A",
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Exam } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{(row.original.owners?.includes(user.id) ||
|
||||
checkAccess(user, ["admin", "developer"])) && (
|
||||
<>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<button
|
||||
data-tip="Edit exam"
|
||||
onClick={() => setSelectedExam(row.original)}
|
||||
className="cursor-pointer tooltip"
|
||||
>
|
||||
<BsPencil />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-tip="Load exam"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={async () =>
|
||||
await loadExam(row.original.module, row.original.id)
|
||||
}
|
||||
>
|
||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteExam(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleExamEdit = () => {
|
||||
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
||||
};
|
||||
const handleExamEdit = () => {
|
||||
router.push(
|
||||
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">Ready to Edit</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full h-full">
|
||||
{renderSearch()}
|
||||
<Modal
|
||||
isOpen={!!selectedExam}
|
||||
onClose={() => setSelectedExam(undefined)}
|
||||
maxWidth="max-w-xl"
|
||||
>
|
||||
{!!selectedExam ? (
|
||||
<>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BiEdit className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-600 font-medium">
|
||||
Ready to Edit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm">Click 'Next' to proceed to the exam editor.</p>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Click 'Next' to proceed to the exam editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2">
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
<div className="flex justify-between gap-4 mt-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedExam(undefined)}
|
||||
className="w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={handleExamEdit}
|
||||
className="w-32 text-white flex items-center justify-center gap-2"
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading ? (
|
||||
<div className="min-h-screen flex justify-center items-start">
|
||||
<span className="loading loading-infinity w-32" />
|
||||
</div>
|
||||
) : (
|
||||
filteredRows.length === 0 && (
|
||||
<div className="w-full flex justify-center items-start">
|
||||
<span className="text-xl text-gray-500">No data found...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ import useFilterStore from "@/stores/listFilterStore";
|
||||
import { useRouter } from "next/router";
|
||||
import { mapBy } from "@/utils";
|
||||
import { exportListToExcel } from "@/utils/users";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import useUserBalance from "@/hooks/useUserBalance";
|
||||
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
|
||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import Table from "@/components/High/Table";
|
||||
@@ -494,21 +492,19 @@ export default function UserList({
|
||||
},
|
||||
];
|
||||
|
||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
||||
const downloadExcel = async (rows: WithLabeledEntities<User>[]) => {
|
||||
if (entitiesDownloadUsers.length === 0)
|
||||
return toast.error("You are not allowed to download the user list.");
|
||||
|
||||
const allowedRows = rows.filter((r) =>
|
||||
mapBy(r.entities, "id").some((e) =>
|
||||
mapBy(entitiesDownloadUsers, "id").includes(e)
|
||||
)
|
||||
);
|
||||
const csv = exportListToExcel(allowedRows);
|
||||
const allowedRows = rows;
|
||||
const csv = await exportListToExcel(allowedRows);
|
||||
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([csv], { type: "text/csv" });
|
||||
const file = new Blob([csv], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "users.csv";
|
||||
element.download = "users.xlsx";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
|
||||
@@ -4,7 +4,6 @@ import clsx from "clsx";
|
||||
import CodeList from "./CodeList";
|
||||
import DiscountList from "./DiscountList";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Code, Group, Type } from "@/interfaces/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { Code, } from "@/interfaces/user";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { doesEntityAllow } from "@/utils/permissions";
|
||||
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { findBy } from "@/utils";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const { entities } = req.query as { entities?: string[] };
|
||||
if (entities)
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray());
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: Array.isArray(entities) ? entities : [entities] } }).toArray());
|
||||
|
||||
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const { entity } = req.query as { entity?: string };
|
||||
|
||||
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
|
||||
|
||||
res.status(200).json(snapshot);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
|
||||
import { generateExamDifferences } from "@/utils/exam.differences";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import { access } from "fs";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
@@ -52,6 +53,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
try {
|
||||
const exam = {
|
||||
access: "public", // default access is public
|
||||
...req.body,
|
||||
module: module,
|
||||
entities,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {flatten} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { flatten, map } from "lodash";
|
||||
import { AccessType, Exam } from "@/interfaces/exam";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { requestUser } from "../../../utils/api";
|
||||
import { mapBy } from "../../../utils";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
@@ -14,17 +16,37 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
const user = await requestUser(req, res)
|
||||
if (!user)
|
||||
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
|
||||
const isAdmin = ["admin", "developer"].includes(user.type)
|
||||
const { entities = [] } = req.query as { access?: AccessType, entities?: string[] | string };
|
||||
let entitiesToFetch = Array.isArray(entities) ? entities : entities ? [entities] : []
|
||||
|
||||
if (!isAdmin) {
|
||||
const userEntitiesIDs = mapBy(user.entities || [], 'id')
|
||||
entitiesToFetch = entities ? entitiesToFetch.filter((entity): entity is string => entity ? userEntitiesIDs.includes(entity) : false) : userEntitiesIDs
|
||||
if ((entitiesToFetch.length ?? 0) === 0) {
|
||||
res.status(200).json([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
|
||||
const snapshot = await db.collection(module).find<Exam>({
|
||||
isDiagnostic: false, ...(isAdmin && (entitiesToFetch.length ?? 0) === 0 ? {
|
||||
} : {
|
||||
entity: { $in: entitiesToFetch }
|
||||
})
|
||||
}).toArray();
|
||||
|
||||
return snapshot.map((doc) => ({
|
||||
...doc,
|
||||
|
||||
@@ -33,7 +33,6 @@ import moment from "moment";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { generate } from "random-words";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {
|
||||
|
||||
@@ -159,6 +159,21 @@ export default function Home({ user, group, users, entity }: Props) {
|
||||
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||
);
|
||||
|
||||
const toggleAllUsersInList = () =>
|
||||
setSelectedUsers((prev) =>
|
||||
prev.length === rows.length
|
||||
? []
|
||||
: [
|
||||
...prev,
|
||||
...items.reduce((acc, i) => {
|
||||
if (!prev.find((item) => item === i.id)) {
|
||||
(acc as string[]).push(i.id);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]),
|
||||
]
|
||||
);
|
||||
|
||||
const removeParticipants = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
if (!canRemoveParticipants) return;
|
||||
@@ -428,6 +443,25 @@ export default function Home({ user, group, users, entity }: Props) {
|
||||
{capitalize(type)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleAllUsersInList();
|
||||
}}
|
||||
disabled={rows.length === 0}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||
(isAdding ? nonParticipantUsers : group.participants)
|
||||
.length === selectedUsers.length &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{"De/Select All"}
|
||||
</button>
|
||||
<span className="opacity-80">
|
||||
{selectedUsers.length} selected
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useState} from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import IconCard from "@/components/IconCard";
|
||||
import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs";
|
||||
|
||||
@@ -71,7 +71,7 @@ export type RolePermission =
|
||||
| "view_workflows"
|
||||
| "configure_workflows"
|
||||
| "edit_workflow"
|
||||
| "delete_workflow";
|
||||
| "delete_workflow" | "view_confidential_exams";
|
||||
|
||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||
"view_students",
|
||||
|
||||
@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number, reset: boolean
|
||||
examLabel: defaultExamLabel(module),
|
||||
minTimer,
|
||||
difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!],
|
||||
isPrivate: true,
|
||||
access: "private",
|
||||
sectionLabels: sectionLabels(module),
|
||||
expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1],
|
||||
focusedSection: 1,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReduce
|
||||
import { Module } from "@/interfaces";
|
||||
import { updateExamWithUserSolutions } from "@/stores/exam/utils";
|
||||
import { defaultExamUserSolutions } from "@/utils/exams";
|
||||
import { access } from "fs";
|
||||
|
||||
type RootActions = { type: 'FULL_RESET' } |
|
||||
{ type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } |
|
||||
@@ -121,7 +122,7 @@ export const rootReducer = (
|
||||
...defaultModuleSettings(examModule, exam.minTimer),
|
||||
examLabel: exam.label,
|
||||
difficulty: exam.difficulty,
|
||||
isPrivate: exam.private,
|
||||
access: exam.access,
|
||||
sections: examState,
|
||||
importModule: false,
|
||||
sectionLabels:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { AccessType, Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { Module } from "@/interfaces";
|
||||
import Option from "@/interfaces/option";
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface LevelSectionSettings extends SectionSettings {
|
||||
isAudioGenerationOpen: boolean;
|
||||
listeningTopic: string;
|
||||
isListeningTopicOpen: boolean;
|
||||
|
||||
|
||||
// speaking
|
||||
speakingTopic?: string;
|
||||
speakingSecondTopic?: string;
|
||||
@@ -87,7 +87,7 @@ export interface LevelSectionSettings extends SectionSettings {
|
||||
|
||||
export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing";
|
||||
export type Generating = Context | "exercises" | string | undefined;
|
||||
export type LevelGenResults = {generating: string, result: Record<string, any>[], module: Module};
|
||||
export type LevelGenResults = { generating: string, result: Record<string, any>[], module: Module };
|
||||
export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
|
||||
export type ExamPart = ListeningPart | ReadingPart | LevelPart;
|
||||
|
||||
@@ -97,10 +97,10 @@ export interface SectionState {
|
||||
state: Section;
|
||||
expandedSubSections: number[];
|
||||
generating: Generating;
|
||||
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined;
|
||||
genResult: { generating: string, result: Record<string, any>[], module: Module } | undefined;
|
||||
levelGenerating: Generating[];
|
||||
levelGenResults: LevelGenResults[];
|
||||
focusedExercise?: {questionId: number; id: string} | undefined;
|
||||
focusedExercise?: { questionId: number; id: string } | undefined;
|
||||
writingSection?: number;
|
||||
speakingSection?: number;
|
||||
readingSection?: number;
|
||||
@@ -111,7 +111,7 @@ export interface SectionState {
|
||||
export interface ListeningInstructionsState {
|
||||
isInstructionsOpen: boolean;
|
||||
chosenOption: Option;
|
||||
|
||||
|
||||
currentInstructions: string;
|
||||
presetInstructions: string;
|
||||
customInstructions: string;
|
||||
@@ -126,8 +126,8 @@ export interface ModuleState {
|
||||
sections: SectionState[];
|
||||
minTimer: number;
|
||||
difficulty: Difficulty[];
|
||||
isPrivate: boolean;
|
||||
sectionLabels: {id: number; label: string;}[];
|
||||
access: AccessType;
|
||||
sectionLabels: { id: number; label: string; }[];
|
||||
expandedSections: number[];
|
||||
focusedSection: number;
|
||||
importModule: boolean;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const getExams = async (
|
||||
.collection(module)
|
||||
.find<Exam>({
|
||||
isDiagnostic: false,
|
||||
access: "public",
|
||||
})
|
||||
.toArray();
|
||||
|
||||
@@ -72,7 +73,7 @@ export const getExams = async (
|
||||
...doc,
|
||||
module,
|
||||
})) as Exam[],
|
||||
).filter((x) => !x.private);
|
||||
)
|
||||
|
||||
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
|
||||
exams = filterByVariant(exams, variant);
|
||||
|
||||
@@ -23,7 +23,7 @@ export const sortByModuleName = (a: string, b: string) => {
|
||||
};
|
||||
|
||||
export const countExercises = (exercises: Exercise[]) => {
|
||||
const lengthMap = exercises.map((e) => {
|
||||
const lengthMap = (exercises ?? []).map((e) => {
|
||||
if (e.type === "multipleChoice") return e.questions.length;
|
||||
if (e.type === "interactiveSpeaking") return e.prompts.length;
|
||||
if (e.type === "fillBlanks") return e.solutions.length;
|
||||
@@ -40,37 +40,37 @@ export const countCurrentExercises = (
|
||||
exercises: Exercise[],
|
||||
exerciseIndex: number,
|
||||
questionIndex?: number
|
||||
) => {
|
||||
) => {
|
||||
return exercises.reduce((acc, exercise, index) => {
|
||||
if (index > exerciseIndex) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (exercise.type === "multipleChoice") {
|
||||
if (index === exerciseIndex && questionIndex !== undefined) {
|
||||
count = questionIndex + 1;
|
||||
} else {
|
||||
count = exercise.questions!.length;
|
||||
if (index > exerciseIndex) {
|
||||
return acc;
|
||||
}
|
||||
} else if (exercise.type === "interactiveSpeaking") {
|
||||
count = exercise.prompts.length;
|
||||
} else if (exercise.type === "fillBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "writeBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "matchSentences") {
|
||||
count = exercise.sentences.length;
|
||||
} else if (exercise.type === "trueFalse") {
|
||||
count = exercise.questions.length;
|
||||
} else {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return acc + count;
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (exercise.type === "multipleChoice") {
|
||||
if (index === exerciseIndex && questionIndex !== undefined) {
|
||||
count = questionIndex + 1;
|
||||
} else {
|
||||
count = exercise.questions!.length;
|
||||
}
|
||||
} else if (exercise.type === "interactiveSpeaking") {
|
||||
count = exercise.prompts.length;
|
||||
} else if (exercise.type === "fillBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "writeBlanks") {
|
||||
count = exercise.solutions.length;
|
||||
} else if (exercise.type === "matchSentences") {
|
||||
count = exercise.sentences.length;
|
||||
} else if (exercise.type === "trueFalse") {
|
||||
count = exercise.questions.length;
|
||||
} else {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return acc + count;
|
||||
}, 0);
|
||||
};
|
||||
};
|
||||
|
||||
export const countFullExams = (stats: Stat[]) => {
|
||||
const sessionExams = groupBySession(stats);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EntityWithRoles, Role } from "@/interfaces/entity";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import { User, Type, userTypes } from "@/interfaces/user";
|
||||
import { RolePermission } from "@/resources/entityPermissions";
|
||||
import axios from "axios";
|
||||
import { findBy, mapBy } from ".";
|
||||
import { isAdmin } from "./users";
|
||||
|
||||
@@ -55,11 +54,11 @@ export function groupAllowedEntitiesByPermissions(
|
||||
const userEntity = userEntityMap.get(entity.id);
|
||||
const role = userEntity
|
||||
? roleCache.get(userEntity.role) ??
|
||||
(() => {
|
||||
const foundRole = entity.roles.find(r => r.id === userEntity.role) || null;
|
||||
roleCache.set(userEntity.role, foundRole);
|
||||
return foundRole;
|
||||
})()
|
||||
(() => {
|
||||
const foundRole = entity.roles.find(r => r.id === userEntity.role) || null;
|
||||
roleCache.set(userEntity.role, foundRole);
|
||||
return foundRole;
|
||||
})()
|
||||
: null;
|
||||
|
||||
permissions.forEach(permission => {
|
||||
@@ -76,7 +75,7 @@ export function groupAllowedEntitiesByPermissions(
|
||||
export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) {
|
||||
if (["admin", "developer"].includes(user?.type)) return entities
|
||||
|
||||
const allowedEntities = entities.filter((e) => doesEntityAllow(user, e, permission))
|
||||
const allowedEntities = (entities ?? []).filter((e) => doesEntityAllow(user, e, permission))
|
||||
return allowedEntities
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {WithLabeledEntities} from "@/interfaces/entity";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {capitalize} from "lodash";
|
||||
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { capitalize } from "lodash";
|
||||
import moment from "moment";
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
export interface UserListRow {
|
||||
name: string;
|
||||
@@ -17,6 +18,22 @@ export interface UserListRow {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const indexToLetter = (index: number): string => {
|
||||
// Base case: if the index is less than 0, return an empty string
|
||||
if (index < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Calculate the quotient for recursion (number of times the letter sequence repeats)
|
||||
const quotient = Math.floor(index / 26);
|
||||
|
||||
// Calculate the remainder for the current letter
|
||||
const remainder = index % 26;
|
||||
|
||||
// Recursively call indexToLetter for the quotient and append the current letter
|
||||
return indexToLetter(quotient - 1) + String.fromCharCode(65 + remainder);
|
||||
};
|
||||
|
||||
export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
||||
const rows: UserListRow[] = rowUsers.map((user) => ({
|
||||
name: user.name,
|
||||
@@ -33,10 +50,31 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
||||
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
|
||||
verified: user.isVerified?.toString() || "FALSE",
|
||||
}));
|
||||
const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification";
|
||||
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("User Data");
|
||||
const border: Partial<ExcelJS.Borders> = { top: { style: 'thin' as ExcelJS.BorderStyle }, left: { style: 'thin' as ExcelJS.BorderStyle }, bottom: { style: 'thin' as ExcelJS.BorderStyle }, right: { style: 'thin' as ExcelJS.BorderStyle } }
|
||||
const header = ['Name', 'Email', 'Type', 'Entities', 'Expiry Date', 'Country', 'Phone', 'Employment/Department', 'Gender', 'Verification'].forEach((item, index) => {
|
||||
const cell = worksheet.getCell(`${indexToLetter(index)}1`);
|
||||
const column = worksheet.getColumn(index + 1);
|
||||
column.width = item.length * 2;
|
||||
cell.value = item;
|
||||
cell.font = { bold: true, size: 16 };
|
||||
cell.border = border;
|
||||
|
||||
return `${header}\n${rowsString}`;
|
||||
});
|
||||
rows.forEach((x, index) => {
|
||||
(Object.keys(x) as (keyof UserListRow)[]).forEach((key, i) => {
|
||||
const cell = worksheet.getCell(`${indexToLetter(i)}${index + 2}`);
|
||||
cell.value = x[key];
|
||||
if (index === 0) {
|
||||
const column = worksheet.getColumn(i + 1);
|
||||
column.width = Math.max(column.width ?? 0, x[key].toString().length * 2);
|
||||
}
|
||||
cell.border = border;
|
||||
});
|
||||
})
|
||||
|
||||
return workbook.xlsx.writeBuffer();
|
||||
};
|
||||
|
||||
export const getUserName = (user?: User) => {
|
||||
|
||||
Reference in New Issue
Block a user