Merge pull request #269 from SELab-2/feat/discussions

feat: Discussions pagina's
This commit is contained in:
Timo De Meyst 2025-05-19 21:28:38 +02:00 committed by GitHub
commit f6c2f71edb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 885 additions and 247 deletions

View file

@ -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(

View file

@ -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[]> {

View file

@ -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({

View file

@ -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 [];
},
};

View file

@ -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);
});
});
});

View file

@ -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, []);

View file

@ -0,0 +1,10 @@
export enum MatchMode {
/**
* Match any
*/
ANY = 'ANY',
/**
* Match all
*/
ALL = 'ALL',
}

View 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>

View 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>

View file

@ -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>

View file

@ -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-for="question in questions"
:key="(question.sequenceNumber, question.content)"
class="border rounded-2xl p-4 shadow-sm bg-white"
>
<SingleQuestion :question="question"></SingleQuestion>
<div v-if="questions.length != 0">
<div
v-for="question in questions"
:key="(question.sequenceNumber, question.content)"
>
<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>

View 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>

View file

@ -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;
"
>
<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
v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container"
>
<input
v-model="answer"
type="text"
placeholder="answer: ..."
class="answer-input"
/>
<button
@click="submitAnswer"
class="submit-button"
<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
"
>
</button>
</div>
<using-query-result
:query-result="answersQuery"
v-slot="answersResponse: { data: AnswersResponse }"
>
<button
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>
<div
v-if="expanded"
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
>
<div
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;
"
<div class="question-actions-container">
<v-textarea
v-if="authService.authState.activeRole === AccountType.Teacher"
:label="t('answer-input-placeholder')"
v-model="answer"
class="answer-field"
density="compact"
rows="1"
variant="outlined"
auto-grow
>
<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"
<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 }"
>
{{ answer.content }}
</div>
<v-btn
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
@click="toggle()"
>
{{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }}
</v-btn>
<div
v-show="expanded"
ref="answersContainer"
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
>
<v-card
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
:key="answerIndex"
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 }}
</v-card-text>
</v-card>
</div>
</using-query-result>
</div>
</div>
</using-query-result>
</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>

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 },
},
{

View 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>

View 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>

View file

@ -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>

View file

@ -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,
);
</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;