diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 7fdefd2d..a38f50e3 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import { Response } from 'express'; import { themes } from '../data/themes.js'; import { FALLBACK_LANG } from '../config.js'; import learningPathService from '../services/learning-paths/learning-path-service.js'; @@ -15,7 +15,7 @@ import { requireFields } from './error-helper.js'; /** * Fetch learning paths based on query parameters. */ -export async function getLearningPaths(req: Request, res: Response): Promise { +export async function getLearningPaths(req: AuthenticatedRequest, res: Response): Promise { const admin = req.query.admin; if (admin) { const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); @@ -59,6 +59,19 @@ export async function getLearningPaths(req: Request, res: Response): Promise theme.hruids); + + const apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup); + const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || []; + let allLearningPaths: LearningPath[] = apiLearningPaths; + + if (req.auth) { + const adminUsername = req.auth.username; + const userLearningPaths = (await learningPathService.getLearningPathsAdministratedBy(adminUsername)) || []; + allLearningPaths = apiLearningPaths.concat(userLearningPaths); + } + + res.json(allLearningPaths); + return; } const learningPaths = await learningPathService.fetchLearningPaths( diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 238a7676..87a03748 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -7,14 +7,20 @@ import { LearningPathTransition } from '../../entities/content/learning-path-tra export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { - return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] }); + return this.findOne( + { + hruid: hruid, + language: language, + }, + { populate: ['nodes', 'nodes.transitions', 'admins'] } + ); } /** * Returns all learning paths which have the given language and whose title OR description contains the * query string. * - * @param query The query string we want to seach for in the title or description. + * @param query The query string we want to search for in the title or description. * @param language The language of the learning paths we want to find. */ public async findByQueryStringAndLanguage(query: string, language: Language): Promise { diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index f681eebb..6a3c0ead 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -18,13 +18,14 @@ export class QuestionRepository extends DwengoEntityRepository { content: question.content, timestamp: new Date(), }); + await this.insert(questionEntity); questionEntity.learningObjectHruid = question.loId.hruid; questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; questionEntity.author = question.author; questionEntity.inGroup = question.inGroup; questionEntity.content = question.content; - return await this.insert(questionEntity); + return questionEntity; } public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { return this.findAll({ diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 263bffaf..77cae652 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -62,6 +62,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { data: learningPaths, }; }, + async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise { const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const params = { all: query, language }; @@ -75,7 +76,8 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { }, async getLearningPathsAdministratedBy(_adminUsername: string) { - return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + // Dwengo API does not have the concept of admins, so we cannot filter by them. + return []; }, }; diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts index 2cc594d1..e37b5748 100644 --- a/backend/tests/services/learning-path/database-learning-path-provider.test.ts +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -14,11 +14,14 @@ import { testLearningObjectEssayQuestion, testLearningObjectMultipleChoice, } from '../../test_assets/content/learning-objects.testdata'; -import { testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata'; +import { testLearningPath02, testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata'; import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service'; import { getTestGroup01, getTestGroup02 } from '../../test_assets/assignments/groups.testdata'; import { Group } from '../../../src/entities/assignments/group.entity.js'; +import { Teacher } from '../../../src/entities/users/teacher.entity.js'; import { RequiredEntityData } from '@mikro-orm/core'; +import { getFooFighters, getLimpBizkit } from '../../test_assets/users/teachers.testdata'; +import { mapToTeacherDTO } from '../../../src/interfaces/teacher'; function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode { const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid); @@ -33,6 +36,8 @@ describe('DatabaseLearningPathProvider', () => { let finalLearningObject: RequiredEntityData; let groupA: Group; let groupB: Group; + let teacherA: Teacher; + let teacherB: Teacher; beforeAll(async () => { await setupTestApp(); @@ -42,6 +47,8 @@ describe('DatabaseLearningPathProvider', () => { finalLearningObject = testLearningObjectEssayQuestion; groupA = getTestGroup01(); groupB = getTestGroup02(); + teacherA = getFooFighters(); + teacherB = getLimpBizkit(); // Place different submissions for group A and B. const submissionRepo = getSubmissionRepository(); @@ -140,4 +147,18 @@ describe('DatabaseLearningPathProvider', () => { expect(result.length).toBe(0); }); }); + + describe('getLearningPathsAdministratedBy', () => { + it('returns the learning path owned by the admin', async () => { + const expectedLearningPath = mapToLearningPath(testLearningPath02, [mapToTeacherDTO(teacherB)]); + const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherB], expectedLearningPath.language); + expect(result.length).toBe(1); + expect(result[0].title).toBe(expectedLearningPath.title); + expect(result[0].description).toBe(expectedLearningPath.description); + }); + it('returns an empty result when querying admins that do not have custom learning paths', async () => { + const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherA], testLearningPath.language); + expect(result.length).toBe(0); + }); + }); }); diff --git a/backend/tests/test_assets/content/learning-paths.testdata.ts b/backend/tests/test_assets/content/learning-paths.testdata.ts index 2a0640f8..a89ce60a 100644 --- a/backend/tests/test_assets/content/learning-paths.testdata.ts +++ b/backend/tests/test_assets/content/learning-paths.testdata.ts @@ -14,10 +14,12 @@ import { testLearningObjectMultipleChoice, testLearningObjectPnNotebooks, } from './learning-objects.testdata'; +import { getLimpBizkit } from '../users/teachers.testdata'; export function makeTestLearningPaths(_em: EntityManager): LearningPath[] { const learningPath01 = mapToLearningPath(testLearningPath01, []); const learningPath02 = mapToLearningPath(testLearningPath02, []); + learningPath02.admins = [getLimpBizkit()]; const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []); const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []); diff --git a/common/src/util/match-mode.ts b/common/src/util/match-mode.ts new file mode 100644 index 00000000..5b261f01 --- /dev/null +++ b/common/src/util/match-mode.ts @@ -0,0 +1,10 @@ +export enum MatchMode { + /** + * Match any + */ + ANY = 'ANY', + /** + * Match all + */ + ALL = 'ALL', +} diff --git a/frontend/src/components/DiscussionSideBarElement.vue b/frontend/src/components/DiscussionSideBarElement.vue new file mode 100644 index 00000000..add09153 --- /dev/null +++ b/frontend/src/components/DiscussionSideBarElement.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/DiscussionsSideBar.vue b/frontend/src/components/DiscussionsSideBar.vue new file mode 100644 index 00000000..5c68e6ac --- /dev/null +++ b/frontend/src/components/DiscussionsSideBar.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 7879af45..217555b3 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -87,14 +87,13 @@ > {{ t("classes") }} - - - - - - - - + + {{ t("discussions") }} + {{ t("discussions") }} diff --git a/frontend/src/components/QandA.vue b/frontend/src/components/QandA.vue index f9b15362..0c669d9d 100644 --- a/frontend/src/components/QandA.vue +++ b/frontend/src/components/QandA.vue @@ -1,6 +1,9 @@ - + diff --git a/frontend/src/components/QuestionBox.vue b/frontend/src/components/QuestionBox.vue new file mode 100644 index 00000000..aac5955f --- /dev/null +++ b/frontend/src/components/QuestionBox.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/components/SingleQuestion.vue b/frontend/src/components/SingleQuestion.vue index 2a600d23..ea60a18f 100644 --- a/frontend/src/components/SingleQuestion.vue +++ b/frontend/src/components/SingleQuestion.vue @@ -5,17 +5,34 @@ import UsingQueryResult from "./UsingQueryResult.vue"; import type { AnswersResponse } from "@/controllers/answers"; import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; + import type { UserDTO } from "@dwengo-1/common/interfaces/user"; import authService from "@/services/auth/auth-service"; + import { useI18n } from "vue-i18n"; import { AccountType } from "@dwengo-1/common/util/account-types"; + const { t } = useI18n(); + const props = defineProps<{ question: QuestionDTO; }>(); const expanded = ref(false); + const answersContainer = ref(null); // Ref for the answers container function toggle(): void { expanded.value = !expanded.value; + + // Scroll to the answers container if expanded + if (expanded.value && answersContainer.value) { + setTimeout(() => { + if (answersContainer.value) { + answersContainer.value.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, 100); + } } function formatDate(timestamp: string | Date): string { @@ -49,141 +66,121 @@ createAnswerMutation.mutate(answerData, { onSuccess: async () => { answer.value = ""; + expanded.value = true; await answersQuery.refetch(); }, }); } } + + function displayNameFor(user: UserDTO) { + if (user.firstName && user.lastName) { + return `${user.firstName} ${user.lastName}`; + } else { + return user.username; + } + } + diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index 73d91c46..0ee95277 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -181,5 +181,15 @@ "current-groups": "Aktuelle Gruppen", "group-size-label": "Gruppengröße", "save": "Speichern", - "unassigned": "Nicht zugewiesen" + "unassigned": "Nicht zugewiesen", + "questions": "Fragen", + "view-questions": "Fragen anzeigen auf ", + "question-input-placeholder": "Ihre Frage...", + "answer-input-placeholder": "Ihre Antwort...", + "answers-toggle-hide": "Antworten verstecken", + "answers-toggle-show": "Antworten anzeigen", + "no-questions": "Keine Fragen", + "no-discussion-tip": "Wählen Sie ein Lernobjekt aus, um dessen Fragen anzuzeigen", + "askAQuestion": "Eine Frage stellen", + "questionsCapitalized": "Fragen" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 1a24d79e..0435a5a8 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -4,7 +4,7 @@ "teacher": "teacher", "assignments": "Assignments", "classes": "Classes", - "discussions": "discussions", + "discussions": "Discussions", "logout": "log out", "error_title": "Error", "previous": "Previous", @@ -182,5 +182,15 @@ "current-groups": "Current groups", "group-size-label": "Group size", "save": "Save", - "unassigned": "Unassigned" + "unassigned": "Unassigned", + "questions": "questions", + "view-questions": "View questions in ", + "question-input-placeholder": "Your question...", + "answer-input-placeholder": "Your answer...", + "answers-toggle-hide": "Hide answers", + "answers-toggle-show": "Show answers", + "no-questions": "No questions asked yet", + "no-discussion-tip": "Choose a learning object to view its questions", + "askAQuestion": "Ask a question", + "questionsCapitalized": "Questions" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index f31033da..1d25f96c 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -182,5 +182,15 @@ "current-groups": "Groupes actuels", "group-size-label": "Taille des groupes", "save": "Enregistrer", - "unassigned": "Non assigné" + "unassigned": "Non assigné", + "questions": "Questions", + "view-questions": "Voir les questions dans ", + "question-input-placeholder": "Votre question...", + "answer-input-placeholder": "Votre réponse...", + "answers-toggle-hide": "Masquer réponses", + "answers-toggle-show": "Afficher réponse", + "no-questions": "Aucune question trouvée", + "no-discussion-tip": "Sélectionnez un objet d'apprentissage pour afficher les questions qui s'y rapportent", + "askAQuestion": "Pose une question", + "questionsCapitalized": "Questions" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 5f4c66a5..d3868286 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -4,7 +4,7 @@ "teacher": "leerkracht", "assignments": "Opdrachten", "classes": "Klassen", - "discussions": "discussies", + "discussions": "Discussies", "logout": "log uit", "error_title": "Fout", "previous": "Vorige", @@ -181,5 +181,15 @@ "current-groups": "Huidige groepen", "group-size-label": "Grootte van groepen", "save": "Opslaan", - "unassigned": "Niet toegewezen" + "unassigned": "Niet toegewezen", + "questions": "vragen", + "view-questions": "Bekijk vragen in ", + "question-input-placeholder": "Uw vraag...", + "answer-input-placeholder": "Uw antwoord...", + "answers-toggle-hide": "Verberg antwoorden", + "answers-toggle-show": "Toon antwoorden", + "no-questions": "Nog geen vragen gesteld", + "no-discussion-tip": "Kies een leerobject om zijn vragen te bekijken", + "askAQuestion": "Stel een vraag", + "questionsCapitalized": "Vragen" } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fa3d6e05..abbe4b52 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,8 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; import SingleTheme from "@/views/SingleTheme.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import authService from "@/services/auth/auth-service"; +import DiscussionForward from "@/views/discussions/DiscussionForward.vue"; +import NoDiscussion from "@/views/discussions/NoDiscussion.vue"; import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; import { allowRedirect, Redirect } from "@/utils/redirect.ts"; @@ -57,12 +59,6 @@ const router = createRouter({ name: "UserClasses", component: UserClasses, }, - // TODO Re-enable this route when the discussion page is ready - // { - // Path: "discussion", - // Name: "UserDiscussions", - // Component: UserDiscussions, - // }, ], }, @@ -102,9 +98,23 @@ const router = createRouter({ meta: { requiresAuth: true }, }, { - path: "/discussion/:id", + path: "/discussion", + name: "Discussions", + component: NoDiscussion, + meta: { requiresAuth: true }, + }, + { + path: "/discussion/:hruid/:language/:learningObjectHruid", name: "SingleDiscussion", component: SingleDiscussion, + props: true, + meta: { requiresAuth: true }, + }, + { + path: "/discussion-reload/:hruid/:language/:learningObjectHruid", + name: "DiscussionForwardWorkaround", + component: DiscussionForward, + props: true, meta: { requiresAuth: true }, }, { diff --git a/frontend/src/views/discussions/DiscussionForward.vue b/frontend/src/views/discussions/DiscussionForward.vue new file mode 100644 index 00000000..4006c3be --- /dev/null +++ b/frontend/src/views/discussions/DiscussionForward.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/views/discussions/NoDiscussion.vue b/frontend/src/views/discussions/NoDiscussion.vue new file mode 100644 index 00000000..0e979ddb --- /dev/null +++ b/frontend/src/views/discussions/NoDiscussion.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/views/discussions/SingleDiscussion.vue b/frontend/src/views/discussions/SingleDiscussion.vue index 1a35a59f..4611823b 100644 --- a/frontend/src/views/discussions/SingleDiscussion.vue +++ b/frontend/src/views/discussions/SingleDiscussion.vue @@ -1,7 +1,195 @@ - + - + diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index a6080855..c41f6063 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -17,11 +17,11 @@ import type { QuestionsResponse } from "@/controllers/questions"; import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; import QandA from "@/components/QandA.vue"; - import type { QuestionData, QuestionDTO } from "@dwengo-1/common/interfaces/question"; + import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students"; import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; - import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import QuestionNotification from "@/components/QuestionNotification.vue"; + import QuestionBox from "@/components/QuestionBox.vue"; import { AccountType } from "@dwengo-1/common/util/account-types"; const router = useRouter(); @@ -150,12 +150,6 @@ const studentAssignmentsQueryResult = useStudentAssignmentsQuery( authService.authState.user?.profile.preferred_username, ); - const pathIsAssignment = computed(() => { - const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || []; - return assignments.some( - (assignment) => assignment.learningPath === props.hruid && assignment.language === props.language, - ); - }); const loID: LearningObjectIdentifierDTO = { hruid: props.learningObjectHruid as string, @@ -166,31 +160,16 @@ const questionInput = ref(""); - function submitQuestion(): void { - const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]; - const assignment = assignments.find( - (assignment) => assignment.learningPath === props.hruid && assignment.language === props.language, - ); - const groups = groupsQueryResult.data.value?.groups as GroupDTO[]; - const group = groups?.find((group) => group.assignment === assignment?.id) as GroupDTO; - const questionData: QuestionData = { - author: authService.authState.user?.profile.preferred_username, - content: questionInput.value, - inGroup: group, //TODO: POST response zegt dat dit null is??? - }; - if (questionInput.value !== "") { - createQuestionMutation.mutate(questionData, { - onSuccess: async () => { - questionInput.value = ""; // Clear the input field after submission - await getQuestionsQuery.refetch(); // Reload the questions - }, - onError: (_) => { - // TODO Handle error - // - console.error(e); - }, - }); - } - } + const discussionLink = computed( + () => + "/discussion" + + "/" + + props.hruid + + "/" + + currentNode.value?.language + + "/" + + currentNode.value?.learningobjectHruid, + ); - - +
-
-
- - -
-
+ +
+ {{ t("questions") }} + + {{ t("view-questions") }} + + {{ t("discussions") }} + + +
+