Merge pull request #269 from SELab-2/feat/discussions
feat: Discussions pagina's
This commit is contained in:
		
						commit
						f6c2f71edb
					
				
					 22 changed files with 885 additions and 247 deletions
				
			
		|  | @ -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<void> { | ||||
| export async function getLearningPaths(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     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<voi | |||
|             return; | ||||
|         } else { | ||||
|             hruidList = themes.flatMap((theme) => 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( | ||||
|  |  | |||
|  | @ -7,14 +7,20 @@ import { LearningPathTransition } from '../../entities/content/learning-path-tra | |||
| 
 | ||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         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<LearningPath[]> { | ||||
|  |  | |||
|  | @ -18,13 +18,14 @@ export class QuestionRepository extends DwengoEntityRepository<Question> { | |||
|             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<Question[]> { | ||||
|         return this.findAll({ | ||||
|  |  | |||
|  | @ -62,6 +62,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
|             data: learningPaths, | ||||
|         }; | ||||
|     }, | ||||
| 
 | ||||
|     async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> { | ||||
|         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 []; | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<LearningObject>; | ||||
|     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); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -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, []); | ||||
|  |  | |||
							
								
								
									
										10
									
								
								common/src/util/match-mode.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								common/src/util/match-mode.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | |||
| export enum MatchMode { | ||||
|     /** | ||||
|      * Match any | ||||
|      */ | ||||
|     ANY = 'ANY', | ||||
|     /** | ||||
|      * Match all | ||||
|      */ | ||||
|     ALL = 'ALL', | ||||
| } | ||||
							
								
								
									
										48
									
								
								frontend/src/components/DiscussionSideBarElement.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/components/DiscussionSideBarElement.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; | ||||
|     import { useLearningObjectListForPathQuery } from "@/queries/learning-objects"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         path: LearningPath; | ||||
|         activeObjectId: string; | ||||
|     }>(); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-expansion-panel :value="props.path.hruid"> | ||||
|         <v-expansion-panel-title> | ||||
|             {{ path.title }} | ||||
|         </v-expansion-panel-title> | ||||
|         <v-expansion-panel-text> | ||||
|             <v-lazy> | ||||
|                 <using-query-result | ||||
|                     :query-result="useLearningObjectListForPathQuery(props.path)" | ||||
|                     v-slot="learningObjects: { data: LearningObject[] }" | ||||
|                 > | ||||
|                     <template | ||||
|                         v-for="node in learningObjects.data" | ||||
|                         :key="node.key" | ||||
|                     > | ||||
|                         <v-list-item | ||||
|                             link | ||||
|                             :to="{ | ||||
|                                 path: `/discussion-reload/${props.path.hruid}/${node.language}/${node.key}`, | ||||
|                                 query: route.query, | ||||
|                             }" | ||||
|                             :title="node.title" | ||||
|                             :active="node.key === props.activeObjectId" | ||||
|                         > | ||||
|                         </v-list-item> | ||||
|                     </template> | ||||
|                 </using-query-result> | ||||
|             </v-lazy> | ||||
|         </v-expansion-panel-text> | ||||
|     </v-expansion-panel> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
							
								
								
									
										70
									
								
								frontend/src/components/DiscussionsSideBar.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								frontend/src/components/DiscussionsSideBar.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import DiscussionSideBarElement from "@/components/DiscussionSideBarElement.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; | ||||
|     import { ref, watch } from "vue"; | ||||
|     import { useRoute } from "vue-router"; | ||||
| 
 | ||||
|     const { t, locale } = useI18n(); | ||||
|     const route = useRoute(); | ||||
| 
 | ||||
|     const navigationDrawerShown = ref(true); | ||||
|     const currentLocale = ref(locale.value); | ||||
|     const expanded = ref([route.params.hruid]); | ||||
| 
 | ||||
|     watch(locale, (newLocale) => { | ||||
|         currentLocale.value = newLocale; | ||||
|     }); | ||||
| 
 | ||||
|     const allLearningPathsResult = useGetAllLearningPaths(() => currentLocale.value); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-navigation-drawer | ||||
|         v-model="navigationDrawerShown" | ||||
|         :width="350" | ||||
|         app | ||||
|     > | ||||
|         <div class="d-flex flex-column h-100"> | ||||
|             <v-list-item> | ||||
|                 <template v-slot:title> | ||||
|                     <div class="title">{{ t("discussions") }}</div> | ||||
|                 </template> | ||||
|             </v-list-item> | ||||
|             <v-divider></v-divider> | ||||
|             <v-expansion-panels v-model="expanded"> | ||||
|                 <using-query-result | ||||
|                     :query-result="allLearningPathsResult" | ||||
|                     v-slot="learningPaths: { data: LearningPath[] }" | ||||
|                 > | ||||
|                     <DiscussionSideBarElement | ||||
|                         v-for="learningPath in learningPaths.data" | ||||
|                         :path="learningPath" | ||||
|                         :activeObjectId="'' as string" | ||||
|                         :key="learningPath.hruid" | ||||
|                     /> | ||||
|                 </using-query-result> | ||||
|             </v-expansion-panels> | ||||
|         </div> | ||||
|     </v-navigation-drawer> | ||||
|     <div class="control-bar-above-content"> | ||||
|         <v-btn | ||||
|             :icon="navigationDrawerShown ? 'mdi-menu-open' : 'mdi-menu'" | ||||
|             class="navigation-drawer-toggle-button" | ||||
|             variant="plain" | ||||
|             @click="navigationDrawerShown = !navigationDrawerShown" | ||||
|         ></v-btn> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .title { | ||||
|         color: #0e6942; | ||||
|         text-transform: uppercase; | ||||
|         font-weight: bolder; | ||||
|         padding-top: 2%; | ||||
|         font-size: 36px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -87,14 +87,13 @@ | |||
|             > | ||||
|                 {{ t("classes") }} | ||||
|             </v-btn> | ||||
|             <!-- TODO Re-enable this button when the discussion page is ready --> | ||||
|             <!--            <v-btn--> | ||||
|             <!--                class="menu_item"--> | ||||
|             <!--                variant="text"--> | ||||
|             <!--                to="/user/discussion"--> | ||||
|             <!--            >--> | ||||
|             <!--                {{ t("discussions") }}--> | ||||
|             <!--            </v-btn>--> | ||||
|             <v-btn | ||||
|                 class="menu_item" | ||||
|                 variant="text" | ||||
|                 to="/discussion" | ||||
|             > | ||||
|                 {{ t("discussions") }} | ||||
|             </v-btn> | ||||
|         </v-toolbar-items> | ||||
|         <v-menu | ||||
|             open-on-hover | ||||
|  | @ -231,7 +230,7 @@ | |||
|             </v-list-item> | ||||
| 
 | ||||
|             <v-list-item | ||||
|                 to="/user/discussion" | ||||
|                 to="/discussion" | ||||
|                 link | ||||
|             > | ||||
|                 <v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title> | ||||
|  |  | |||
|  | @ -1,6 +1,9 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; | ||||
|     import SingleQuestion from "./SingleQuestion.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     defineProps<{ | ||||
|         questions: QuestionDTO[]; | ||||
|  | @ -8,13 +11,28 @@ | |||
| </script> | ||||
| <template> | ||||
|     <div class="space-y-4"> | ||||
|         <div v-if="questions.length != 0"> | ||||
|             <div | ||||
|                 v-for="question in questions" | ||||
|                 :key="(question.sequenceNumber, question.content)" | ||||
|             class="border rounded-2xl p-4 shadow-sm bg-white" | ||||
|             > | ||||
|                 <SingleQuestion :question="question"></SingleQuestion> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|             <p class="no-questions">{{ t("no-questions") }}</p> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .no-questions { | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         height: 40vh; | ||||
|         text-align: center; | ||||
|         font-size: 18px; | ||||
|         color: #666; | ||||
|         padding: 0 20px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
							
								
								
									
										134
									
								
								frontend/src/components/QuestionBox.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								frontend/src/components/QuestionBox.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,134 @@ | |||
| <script setup lang="ts"> | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import { computed, type ComputedRef, ref } from "vue"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
|     import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students.ts"; | ||||
|     import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; | ||||
|     import type { QuestionData } from "@dwengo-1/common/interfaces/question"; | ||||
|     import type { LearningObjectIdentifierDTO } from "@dwengo-1/interfaces/learning-content"; | ||||
|     import { useCreateQuestionMutation, useQuestionsQuery } from "@/queries/questions.ts"; | ||||
|     import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import { useLearningObjectListForPathQuery } from "@/queries/learning-objects.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { AccountType } from "@dwengo-1/common/util/account-types.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         learningObjectHruid?: string; | ||||
|         forGroup?: GroupDTOId | undefined; | ||||
|         withTitle?: boolean; | ||||
|     }>(); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const studentAssignmentsQueryResult = useStudentAssignmentsQuery( | ||||
|         authService.authState.user?.profile.preferred_username, | ||||
|     ); | ||||
|     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, props.forGroup); | ||||
|     const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); | ||||
| 
 | ||||
|     const pathIsAssignment = computed(() => { | ||||
|         const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || []; | ||||
|         return assignments.some( | ||||
|             (assignment) => assignment.learningPath === props.hruid && assignment.language === props.language, | ||||
|         ); | ||||
|     }); | ||||
| 
 | ||||
|     const nodesList: ComputedRef<LearningPathNode[] | null> = computed( | ||||
|         () => learningPathQueryResult.data.value?.nodesAsList ?? null, | ||||
|     ); | ||||
| 
 | ||||
|     const currentNode = computed(() => { | ||||
|         const currentHruid = props.learningObjectHruid; | ||||
|         return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid); | ||||
|     }); | ||||
| 
 | ||||
|     const getQuestionsQuery = useQuestionsQuery( | ||||
|         computed( | ||||
|             () => | ||||
|                 ({ | ||||
|                     language: currentNode.value?.language, | ||||
|                     hruid: currentNode.value?.learningobjectHruid, | ||||
|                     version: currentNode.value?.version, | ||||
|                 }) as LearningObjectIdentifierDTO, | ||||
|         ), | ||||
|     ); | ||||
| 
 | ||||
|     const questionInput = ref(""); | ||||
| 
 | ||||
|     const loID: ComputedRef<LearningObjectIdentifierDTO> = computed(() => ({ | ||||
|         hruid: props.learningObjectHruid as string, | ||||
|         language: props.language, | ||||
|     })); | ||||
|     const createQuestionMutation = useCreateQuestionMutation(loID); | ||||
|     const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username); | ||||
| 
 | ||||
|     const showQuestionBox = computed( | ||||
|         () => authService.authState.activeRole === AccountType.Student && pathIsAssignment.value, | ||||
|     ); | ||||
| 
 | ||||
|     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, | ||||
|         }; | ||||
|         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); | ||||
|                 }, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <h3 v-if="props.withTitle && showQuestionBox">{{ t("askAQuestion") }}:</h3> | ||||
|     <div | ||||
|         class="question-box" | ||||
|         v-if="showQuestionBox" | ||||
|     > | ||||
|         <v-textarea | ||||
|             :label="t('question-input-placeholder')" | ||||
|             v-model="questionInput" | ||||
|             class="question-field" | ||||
|             density="compact" | ||||
|             rows="1" | ||||
|             variant="outlined" | ||||
|             auto-grow | ||||
|         > | ||||
|             <template v-slot:append-inner> | ||||
|                 <v-btn | ||||
|                     icon="mdi mdi-send" | ||||
|                     size="small" | ||||
|                     variant="plain" | ||||
|                     class="question-button" | ||||
|                     @click="submitQuestion" | ||||
|                 /> | ||||
|             </template> | ||||
|         </v-textarea> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .question-box { | ||||
|         width: 100%; | ||||
|         max-width: 400px; | ||||
|         margin: 20px auto; | ||||
|     } | ||||
| </style> | ||||
|  | @ -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<HTMLElement | null>(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; | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| <template> | ||||
|     <div class="space-y-4"> | ||||
|         <div | ||||
|             class="flex justify-between items-center mb-2" | ||||
|             style=" | ||||
|                 margin-right: 5px; | ||||
|                 margin-left: 5px; | ||||
|                 font-weight: bold; | ||||
|                 display: flex; | ||||
|                 flex-direction: row; | ||||
|                 justify-content: space-between; | ||||
|         <v-card class="question-card"> | ||||
|             <v-card-title class="author-title">{{ displayNameFor(question.author) }}</v-card-title> | ||||
|             <v-card-subtitle>{{ formatDate(question.timestamp) }}</v-card-subtitle> | ||||
|             <v-card-text> | ||||
|                 {{ question.content }} | ||||
|             </v-card-text> | ||||
|             <template | ||||
|                 v-slot:actions | ||||
|                 v-if=" | ||||
|                     authService.authState.activeRole === AccountType.Teacher || | ||||
|                     answersQuery.data?.value?.answers?.length > 0 | ||||
|                 " | ||||
|             > | ||||
|             <span class="font-semibold text-lg text-gray-800">{{ | ||||
|                 question.author.firstName + " " + question.author.lastName | ||||
|             }}</span> | ||||
|             <span class="text-sm text-gray-500">{{ formatDate(question.timestamp) }}</span> | ||||
|         </div> | ||||
| 
 | ||||
|         <div | ||||
|             class="text-gray-700 mb-3" | ||||
|             style="margin-left: 10px" | ||||
|         > | ||||
|             {{ question.content }} | ||||
|         </div> | ||||
|         <div | ||||
|                 <div class="question-actions-container"> | ||||
|                     <v-textarea | ||||
|                         v-if="authService.authState.activeRole === AccountType.Teacher" | ||||
|             class="answer-input-container" | ||||
|         > | ||||
|             <input | ||||
|                         :label="t('answer-input-placeholder')" | ||||
|                         v-model="answer" | ||||
|                 type="text" | ||||
|                 placeholder="answer: ..." | ||||
|                 class="answer-input" | ||||
|             /> | ||||
|             <button | ||||
|                 @click="submitAnswer" | ||||
|                 class="submit-button" | ||||
|                         class="answer-field" | ||||
|                         density="compact" | ||||
|                         rows="1" | ||||
|                         variant="outlined" | ||||
|                         auto-grow | ||||
|                     > | ||||
|                 ▶ | ||||
|             </button> | ||||
|         </div> | ||||
|                         <template v-slot:append-inner> | ||||
|                             <v-btn | ||||
|                                 icon="mdi mdi-send" | ||||
|                                 size="small" | ||||
|                                 variant="plain" | ||||
|                                 class="answer-button" | ||||
|                                 @click="submitAnswer" | ||||
|                             /> | ||||
|                         </template> | ||||
|                     </v-textarea> | ||||
|                     <using-query-result | ||||
|                         :query-result="answersQuery" | ||||
|                         v-slot="answersResponse: { data: AnswersResponse }" | ||||
|                     > | ||||
|             <button | ||||
|                         <v-btn | ||||
|                             v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0" | ||||
|                             @click="toggle()" | ||||
|                 class="text-blue-600 hover:underline text-sm" | ||||
|                         > | ||||
|                 {{ expanded ? "Hide Answers" : "Show Answers" }} | ||||
|             </button> | ||||
|                             {{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }} | ||||
|                         </v-btn> | ||||
| 
 | ||||
|                         <div | ||||
|                 v-if="expanded" | ||||
|                             v-show="expanded" | ||||
|                             ref="answersContainer" | ||||
|                             class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2" | ||||
|                         > | ||||
|                 <div | ||||
|                             <v-card | ||||
|                                 v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]" | ||||
|                                 :key="answerIndex" | ||||
|                     class="text-gray-600" | ||||
|                 > | ||||
|                     <div | ||||
|                         class="flex justify-between items-center mb-2" | ||||
|                         style=" | ||||
|                             margin-right: 5px; | ||||
|                             margin-left: 5px; | ||||
|                             font-weight: bold; | ||||
|                             display: flex; | ||||
|                             flex-direction: row; | ||||
|                             justify-content: space-between; | ||||
|                         " | ||||
|                     > | ||||
|                         <span class="font-semibold text-lg text-gray-800">{{ answer.author.username }}</span> | ||||
|                         <span class="text-sm text-gray-500">{{ formatDate(answer.timestamp) }}</span> | ||||
|                     </div> | ||||
| 
 | ||||
|                     <div | ||||
|                         class="text-gray-700 mb-3" | ||||
|                         style="margin-left: 10px" | ||||
|                                 class="answer-card" | ||||
|                             > | ||||
|                                 <v-card-title class="author-title">{{ displayNameFor(answer.author) }}</v-card-title> | ||||
|                                 <v-card-subtitle>{{ formatDate(answer.timestamp) }}</v-card-subtitle> | ||||
|                                 <v-card-text> | ||||
|                                     {{ answer.content }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                                 </v-card-text> | ||||
|                             </v-card> | ||||
|                         </div> | ||||
|                     </using-query-result> | ||||
|                 </div> | ||||
|             </template> | ||||
|         </v-card> | ||||
|     </div> | ||||
| </template> | ||||
| <style scoped> | ||||
|     .answer-input { | ||||
|         flex-grow: 1; | ||||
|         outline: none; | ||||
|         border: none; | ||||
|         background: transparent; | ||||
|         color: #374151; /* gray-700 */ | ||||
|         font-size: 0.875rem; /* smaller font size */ | ||||
|     .answer-field { | ||||
|         max-width: 500px; | ||||
|     } | ||||
| 
 | ||||
|     .answer-input::placeholder { | ||||
|         color: #9ca3af; /* gray-400 */ | ||||
|     .answer-button { | ||||
|         margin: auto; | ||||
|     } | ||||
| 
 | ||||
|     .submit-button { | ||||
|         margin-left: 0.25rem; | ||||
|         padding: 0.25rem; | ||||
|         background-color: #f3f4f6; /* gray-100 */ | ||||
|         border-radius: 9999px; | ||||
|         transition: background-color 0.2s; | ||||
|         border: none; | ||||
|         cursor: pointer; | ||||
|     } | ||||
| 
 | ||||
|     .submit-button:hover { | ||||
|         background-color: #e5e7eb; /* gray-200 */ | ||||
|     } | ||||
| 
 | ||||
|     .submit-icon { | ||||
|         width: 0.75rem; | ||||
|         height: 0.75rem; | ||||
|         color: #4b5563; /* gray-600 */ | ||||
|     } | ||||
|     .answer-input-container { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         border: 1px solid #d1d5db; /* gray-300 */ | ||||
|         border-radius: 9999px; | ||||
|         padding: 0.5rem 1rem; | ||||
|         max-width: 28rem; | ||||
|         margin: 5px; | ||||
|     } | ||||
| 
 | ||||
|     .question-card { | ||||
|         margin: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .question-actions-container { | ||||
|         width: 100%; | ||||
|         margin-left: 10px; | ||||
|         margin-right: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .answer-card { | ||||
|         margin-top: 10px; | ||||
|         margin-bottom: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .author-title { | ||||
|         font-size: 14pt; | ||||
|         margin-bottom: -10px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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" | ||||
| } | ||||
|  |  | |||
|  | @ -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" | ||||
| } | ||||
|  |  | |||
|  | @ -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" | ||||
| } | ||||
|  |  | |||
|  | @ -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" | ||||
| } | ||||
|  |  | |||
|  | @ -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 }, | ||||
|         }, | ||||
|         { | ||||
|  |  | |||
							
								
								
									
										25
									
								
								frontend/src/views/discussions/DiscussionForward.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/views/discussions/DiscussionForward.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { Language } from "@/data-objects/language"; | ||||
|     import { onMounted } from "vue"; | ||||
|     import { useRouter } from "vue-router"; | ||||
| 
 | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         learningObjectHruid?: string; | ||||
|     }>(); | ||||
| 
 | ||||
|     const discussionURL = "/discussion" + "/" + props.hruid + "/" + props.language + "/" + props.learningObjectHruid; | ||||
| 
 | ||||
|     onMounted(async () => { | ||||
|         await router.replace(discussionURL); | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
							
								
								
									
										105
									
								
								frontend/src/views/discussions/NoDiscussion.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								frontend/src/views/discussions/NoDiscussion.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,105 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <DiscussionsSideBar></DiscussionsSideBar> | ||||
|     <div> | ||||
|         <p class="no-discussion-tip">{{ t("no-discussion-tip") }}</p> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .no-discussion-tip { | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         height: 100vh; | ||||
|         text-align: center; | ||||
|         font-size: 18px; | ||||
|         color: #666; | ||||
|         padding: 0 20px; | ||||
|     } | ||||
|     .learning-path-title { | ||||
|         white-space: normal; | ||||
|     } | ||||
|     .search-field-container { | ||||
|         min-width: 250px; | ||||
|     } | ||||
|     .control-bar-above-content { | ||||
|         margin-left: 5px; | ||||
|         margin-right: 5px; | ||||
|         margin-bottom: -30px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|     .learning-object-view-container { | ||||
|         padding-left: 20px; | ||||
|         padding-right: 20px; | ||||
|         padding-bottom: 20px; | ||||
|     } | ||||
|     .navigation-buttons-container { | ||||
|         padding: 20px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|     .assignment-indicator { | ||||
|         position: absolute; | ||||
|         bottom: 10px; | ||||
|         left: 10px; | ||||
|         padding: 4px 12px; | ||||
|         border: 2px solid #f8bcbc; | ||||
|         border-radius: 20px; | ||||
|         color: #f36c6c; | ||||
|         background-color: rgba(248, 188, 188, 0.1); | ||||
|         font-weight: bold; | ||||
|         font-family: Arial, sans-serif; | ||||
|         font-size: 14px; | ||||
|         text-transform: uppercase; | ||||
|         z-index: 2; /* Less than modals/popups */ | ||||
|     } | ||||
|     .question-box { | ||||
|         width: 100%; | ||||
|         max-width: 400px; | ||||
|         margin: 20px auto; | ||||
|         font-family: sans-serif; | ||||
|     } | ||||
|     .input-wrapper { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         border: 1px solid #ccc; | ||||
|         border-radius: 999px; | ||||
|         padding: 8px 12px; | ||||
|         box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | ||||
|     } | ||||
| 
 | ||||
|     .question-input { | ||||
|         flex: 1; | ||||
|         border: none; | ||||
|         outline: none; | ||||
|         font-size: 14px; | ||||
|         background-color: transparent; | ||||
|     } | ||||
| 
 | ||||
|     .question-input::placeholder { | ||||
|         color: #999; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link { | ||||
|         margin-top: 8px; | ||||
|         font-size: 13px; | ||||
|         color: #444; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link a { | ||||
|         color: #3b82f6; /* blue */ | ||||
|         text-decoration: none; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link a:hover { | ||||
|         text-decoration: underline; | ||||
|     } | ||||
| </style> | ||||
|  | @ -1,7 +1,195 @@ | |||
| <script setup lang="ts"></script> | ||||
| <script setup lang="ts"> | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import { computed, type ComputedRef, watch } from "vue"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||
|     import { useQuestionsQuery } from "@/queries/questions"; | ||||
|     import type { QuestionsResponse } from "@/controllers/questions"; | ||||
|     import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
|     import QandA from "@/components/QandA.vue"; | ||||
|     import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; | ||||
|     import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue"; | ||||
|     import QuestionBox from "@/components/QuestionBox.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         learningObjectHruid?: string; | ||||
|     }>(); | ||||
| 
 | ||||
|     interface LearningPathPageQuery { | ||||
|         forGroup?: string; | ||||
|         assignmentNo?: string; | ||||
|         classId?: string; | ||||
|     } | ||||
| 
 | ||||
|     const query = computed(() => route.query as LearningPathPageQuery); | ||||
| 
 | ||||
|     const forGroup = computed(() => { | ||||
|         if (query.value.forGroup && query.value.assignmentNo && query.value.classId) { | ||||
|             return { | ||||
|                 forGroup: parseInt(query.value.forGroup), | ||||
|                 assignmentNo: parseInt(query.value.assignmentNo), | ||||
|                 classId: query.value.classId, | ||||
|             }; | ||||
|         } | ||||
|         return undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup); | ||||
| 
 | ||||
|     const nodesList: ComputedRef<LearningPathNode[] | null> = computed( | ||||
|         () => learningPathQueryResult.data.value?.nodesAsList ?? null, | ||||
|     ); | ||||
| 
 | ||||
|     const currentNode = computed(() => { | ||||
|         const currentHruid = props.learningObjectHruid; | ||||
|         return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid); | ||||
|     }); | ||||
| 
 | ||||
|     const getQuestionsQuery = useQuestionsQuery( | ||||
|         computed( | ||||
|             () => | ||||
|                 ({ | ||||
|                     language: currentNode.value?.language, | ||||
|                     hruid: currentNode.value?.learningobjectHruid, | ||||
|                     version: currentNode.value?.version, | ||||
|                 }) as LearningObjectIdentifierDTO, | ||||
|         ), | ||||
|     ); | ||||
| 
 | ||||
|     watch( | ||||
|         () => [route.params.hruid, route.params.language, route.params.learningObjectHruid], | ||||
|         () => { | ||||
|             //TODO: moet op een of andere manier createQuestionMutation opnieuw kunnen instellen | ||||
|             //      Momenteel opgelost door de DiscussionsForward page workaround | ||||
|         }, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
|     <DiscussionsSideBar></DiscussionsSideBar> | ||||
|     <div class="discussions-container"> | ||||
|         <QuestionBox | ||||
|             :hruid="props.hruid" | ||||
|             :language="props.language" | ||||
|             :learningObjectHruid="props.learningObjectHruid" | ||||
|             :forGroup="forGroup" | ||||
|             withTitle | ||||
|         /> | ||||
|         <h3>{{ t("questionsCapitalized") }}:</h3> | ||||
|         <using-query-result | ||||
|             :query-result="getQuestionsQuery" | ||||
|             v-slot="questionsResponse: { data: QuestionsResponse }" | ||||
|         > | ||||
|             <QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" /> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .discussions-container { | ||||
|         margin: 20px; | ||||
|     } | ||||
| 
 | ||||
|     .learning-path-title { | ||||
|         white-space: normal; | ||||
|     } | ||||
| 
 | ||||
|     .search-field-container { | ||||
|         min-width: 250px; | ||||
|     } | ||||
| 
 | ||||
|     .control-bar-above-content { | ||||
|         margin-left: 5px; | ||||
|         margin-right: 5px; | ||||
|         margin-bottom: -30px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
| 
 | ||||
|     .learning-object-view-container { | ||||
|         padding-left: 20px; | ||||
|         padding-right: 20px; | ||||
|         padding-bottom: 20px; | ||||
|     } | ||||
| 
 | ||||
|     .navigation-buttons-container { | ||||
|         padding: 20px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
| 
 | ||||
|     .assignment-indicator { | ||||
|         position: absolute; | ||||
|         bottom: 10px; | ||||
|         left: 10px; | ||||
|         padding: 4px 12px; | ||||
|         border: 2px solid #f8bcbc; | ||||
|         border-radius: 20px; | ||||
|         color: #f36c6c; | ||||
|         background-color: rgba(248, 188, 188, 0.1); | ||||
|         font-weight: bold; | ||||
|         font-family: Arial, sans-serif; | ||||
|         font-size: 14px; | ||||
|         text-transform: uppercase; | ||||
|         z-index: 2; /* Less than modals/popups */ | ||||
|     } | ||||
| 
 | ||||
|     .question-box { | ||||
|         width: 100%; | ||||
|         max-width: 400px; | ||||
|         margin: 20px auto; | ||||
|         font-family: sans-serif; | ||||
|     } | ||||
| 
 | ||||
|     .input-wrapper { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         border: 1px solid #ccc; | ||||
|         border-radius: 999px; | ||||
|         padding: 8px 12px; | ||||
|         box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | ||||
|     } | ||||
| 
 | ||||
|     .question-input { | ||||
|         flex: 1; | ||||
|         border: none; | ||||
|         outline: none; | ||||
|         font-size: 14px; | ||||
|         background-color: transparent; | ||||
|     } | ||||
| 
 | ||||
|     .question-input::placeholder { | ||||
|         color: #999; | ||||
|     } | ||||
| 
 | ||||
|     .send-button { | ||||
|         background: none; | ||||
|         border: none; | ||||
|         cursor: pointer; | ||||
|         font-size: 16px; | ||||
|         color: #555; | ||||
|         transition: color 0.2s ease; | ||||
|     } | ||||
| 
 | ||||
|     .send-button:hover { | ||||
|         color: #000; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link a { | ||||
|         color: #3b82f6; /* blue */ | ||||
|         text-decoration: none; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link a:hover { | ||||
|         text-decoration: underline; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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 discussionLink = computed( | ||||
|         () => | ||||
|             "/discussion" + | ||||
|             "/" + | ||||
|             props.hruid + | ||||
|             "/" + | ||||
|             currentNode.value?.language + | ||||
|             "/" + | ||||
|             currentNode.value?.learningobjectHruid, | ||||
|     ); | ||||
|         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); | ||||
|                 }, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -236,7 +215,7 @@ | |||
|                         </p> | ||||
|                     </template> | ||||
|                 </v-list-item> | ||||
|                 <v-list-itemF | ||||
|                 <v-list-item | ||||
|                     v-if=" | ||||
|                         query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher | ||||
|                     " | ||||
|  | @ -248,7 +227,7 @@ | |||
|                             v-model="forGroupQueryParam" | ||||
|                         /> | ||||
|                     </template> | ||||
|                 </v-list-itemF> | ||||
|                 </v-list-item> | ||||
|                 <v-divider></v-divider> | ||||
|                 <div> | ||||
|                     <using-query-result | ||||
|  | @ -329,25 +308,6 @@ | |||
|                 v-if="currentNode" | ||||
|             ></learning-object-view> | ||||
|         </div> | ||||
|         <div | ||||
|             v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment" | ||||
|             class="question-box" | ||||
|         > | ||||
|             <div class="input-wrapper"> | ||||
|                 <input | ||||
|                     type="text" | ||||
|                     placeholder="question : ..." | ||||
|                     class="question-input" | ||||
|                     v-model="questionInput" | ||||
|                 /> | ||||
|                 <button | ||||
|                     @click="submitQuestion" | ||||
|                     class="send-button" | ||||
|                 > | ||||
|                     ▶ | ||||
|                 </button> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="navigation-buttons-container"> | ||||
|             <v-btn | ||||
|                 prepend-icon="mdi-chevron-left" | ||||
|  | @ -367,15 +327,43 @@ | |||
|             </v-btn> | ||||
|         </div> | ||||
|         <using-query-result | ||||
|             v-if="forGroup" | ||||
|             :query-result="getQuestionsQuery" | ||||
|             v-slot="questionsResponse: { data: QuestionsResponse }" | ||||
|         > | ||||
|             <v-divider :thickness="6"></v-divider> | ||||
|             <div class="question-header"> | ||||
|                 <span class="question-title">{{ t("questions") }}</span> | ||||
|                 <span class="discussion-link-text"> | ||||
|                     {{ t("view-questions") }} | ||||
|                     <router-link :to="discussionLink"> | ||||
|                         {{ t("discussions") }} | ||||
|                     </router-link> | ||||
|                 </span> | ||||
|             </div> | ||||
|             <QuestionBox | ||||
|                 :hruid="props.hruid" | ||||
|                 :language="props.language" | ||||
|                 :learningObjectHruid="props.learningObjectHruid" | ||||
|                 :forGroup="forGroup" | ||||
|             /> | ||||
|             <QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" /> | ||||
|         </using-query-result> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .question-title { | ||||
|         color: #0e6942; | ||||
|         text-transform: uppercase; | ||||
|         font-weight: bolder; | ||||
|         font-size: 24px; | ||||
|     } | ||||
|     .question-header { | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         padding: 10px; | ||||
|     } | ||||
|     .learning-path-title { | ||||
|         white-space: normal; | ||||
|     } | ||||
|  | @ -414,45 +402,6 @@ | |||
|         text-transform: uppercase; | ||||
|         z-index: 2; /* Less than modals/popups */ | ||||
|     } | ||||
|     .question-box { | ||||
|         width: 100%; | ||||
|         max-width: 400px; | ||||
|         margin: 20px auto; | ||||
|         font-family: sans-serif; | ||||
|     } | ||||
|     .input-wrapper { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         border: 1px solid #ccc; | ||||
|         border-radius: 999px; | ||||
|         padding: 8px 12px; | ||||
|         box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | ||||
|     } | ||||
| 
 | ||||
|     .question-input { | ||||
|         flex: 1; | ||||
|         border: none; | ||||
|         outline: none; | ||||
|         font-size: 14px; | ||||
|         background-color: transparent; | ||||
|     } | ||||
| 
 | ||||
|     .question-input::placeholder { | ||||
|         color: #999; | ||||
|     } | ||||
| 
 | ||||
|     .send-button { | ||||
|         background: none; | ||||
|         border: none; | ||||
|         cursor: pointer; | ||||
|         font-size: 16px; | ||||
|         color: #555; | ||||
|         transition: color 0.2s ease; | ||||
|     } | ||||
| 
 | ||||
|     .send-button:hover { | ||||
|         color: #000; | ||||
|     } | ||||
| 
 | ||||
|     .discussion-link { | ||||
|         margin-top: 8px; | ||||
|  |  | |||
		Reference in a new issue
	
	 Timo De Meyst
						Timo De Meyst