Merge dev into feat/progress-bar

This commit is contained in:
Joyelle Ndagijimana 2025-05-17 01:27:19 +02:00
commit 43317c5dee
120 changed files with 4409 additions and 1520 deletions

View file

@ -3,6 +3,7 @@
import { useI18n } from "vue-i18n";
import { onMounted, ref, type Ref } from "vue";
import auth from "../services/auth/auth-service.ts";
import { Redirect } from "@/utils/redirect.ts";
const { t } = useI18n();
@ -10,10 +11,20 @@
const errorMessage: Ref<string | null> = ref(null);
async function redirectPage(): Promise<void> {
const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY);
if (redirectUrl) {
localStorage.removeItem(Redirect.AFTER_LOGIN_KEY);
await router.replace(redirectUrl);
} else {
await router.replace(Redirect.HOME);
}
}
onMounted(async () => {
try {
await auth.handleLoginCallback();
await router.replace("/user"); // Redirect to theme page
await redirectPage();
} catch (error) {
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
}

View file

@ -3,6 +3,7 @@
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
import auth from "@/services/auth/auth-service.ts";
import { watch } from "vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
@ -17,11 +18,11 @@
);
async function loginAsStudent(): Promise<void> {
await auth.loginAs("student");
await auth.loginAs(AccountType.Student);
}
async function loginAsTeacher(): Promise<void> {
await auth.loginAs("teacher");
await auth.loginAs(AccountType.Teacher);
}
</script>

View file

@ -2,10 +2,11 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import LearningPathsGrid from "@/components/LearningPathsGrid.vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts";
import { useGetAllLearningPathsByThemeAndLanguageQuery } from "@/queries/learning-paths.ts";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useThemeQuery } from "@/queries/themes.ts";
import type { Language } from "@/data-objects/language";
const props = defineProps<{ theme: string }>();
@ -16,7 +17,10 @@
const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme));
const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme);
const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeAndLanguageQuery(
() => props.theme,
() => locale.value as Language,
);
const { t } = useI18n();
const searchFilter = ref("");
@ -31,13 +35,14 @@
</script>
<template>
<div class="container">
<div class="container d-flex flex-column align-items-center justify-center">
<using-query-result :query-result="themeQueryResult">
<h1>{{ currentThemeInfo!!.title }}</h1>
<p>{{ currentThemeInfo!!.description }}</p>
<div class="search-field-container">
<br />
<div class="search-field-container mt-sm-6">
<v-text-field
class="search-field"
class="search-field mx-auto"
:label="t('search')"
append-inner-icon="mdi-magnify"
v-model="searchFilter"
@ -56,13 +61,15 @@
<style scoped>
.search-field-container {
display: block;
margin: 20px;
justify-content: center !important;
}
.search-field {
max-width: 300px;
width: 25%;
min-width: 300px;
}
.container {
padding: 20px;
justify-content: center;
justify-items: center;
}
</style>

View file

@ -14,6 +14,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const route = useRoute();
const router = useRouter();
@ -23,7 +24,7 @@
onMounted(async () => {
// Redirect student
if (role.value === "student") {
if (role.value === AccountType.Student) {
await router.push("/user");
}

View file

@ -4,9 +4,10 @@
import StudentAssignment from "@/views/assignments/StudentAssignment.vue";
import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue";
import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole;
const isTeacher = computed(() => role === "teacher");
const isTeacher = computed(() => role === AccountType.Teacher);
const route = useRoute();
const classId = ref<string>(route.params.classId as string);

View file

@ -9,6 +9,7 @@
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { asyncComputed } from "@vueuse/core";
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types";
import "../../assets/common.css";
const { t, locale } = useI18n();
@ -17,7 +18,7 @@
const role = ref(auth.authState.activeRole);
const username = ref<string>("");
const isTeacher = computed(() => role.value === "teacher");
const isTeacher = computed(() => role.value === AccountType.Teacher);
// Fetch and store all the teacher's classes
let classesQueryResults = undefined;

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { useClassQuery } from "@/queries/classes";
import { defineProps } from "vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const props = defineProps({
classId: String,
});
const classQuery = useClassQuery(props.classId);
</script>
<template>
<using-query-result
:query-result="classQuery"
v-slot="{ data: classResponse }"
>
<span>{{ classResponse?.class.displayName }}</span>
</using-query-result>
</template>

View file

@ -77,7 +77,7 @@
},
onError: (e) => {
dialog.value = false;
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -105,7 +105,7 @@
}
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -126,7 +126,7 @@
usernameTeacher.value = "";
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
});
}

View file

@ -2,7 +2,7 @@
import { useI18n } from "vue-i18n";
import authState from "@/services/auth/auth-service.ts";
import { computed, onMounted, ref } from "vue";
import { validate, version } from "uuid";
import { useRoute } from "vue-router";
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
@ -15,6 +15,7 @@
import "../../assets/common.css";
const { t } = useI18n();
const route = useRoute();
// Username of logged in student
const username = ref<string | undefined>(undefined);
@ -38,6 +39,11 @@
} finally {
isLoading.value = false;
}
const queryCode = route.query.code as string | undefined;
if (queryCode) {
code.value = queryCode;
}
});
// Fetch all classes of the logged in student
@ -75,11 +81,15 @@
// The code a student sends in to join a class needs to be formatted as v4 to be valid
// These rules are used to display a message to the user if they use a code that has an invalid format
function codeRegex(value: string): boolean {
return /^[a-zA-Z0-9]{6}$/.test(value);
}
const codeRules = [
(value: string | undefined): string | boolean => {
if (value === undefined || value === "") {
return true;
} else if (value !== undefined && validate(value) && version(value) === 4) {
} else if (codeRegex(value)) {
return true;
}
return t("invalidFormat");
@ -92,7 +102,7 @@
// Function called when a student submits a code to join a class
function submitCode(): void {
// Check if the code is valid
if (code.value !== undefined && validate(code.value) && version(code.value) === 4) {
if (code.value !== undefined && codeRegex(code.value)) {
mutate(
{ username: username.value!, classId: code.value },
{
@ -100,7 +110,7 @@
showSnackbar(t("sent"), "success");
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -260,7 +270,7 @@
<v-text-field
label="CODE"
v-model="code"
placeholder="XXXXXXXX-XXXX-4XXX-XXXX-XXXXXXXXXXXX"
placeholder="XXXXXX"
:rules="codeRules"
variant="outlined"
></v-text-field>

View file

@ -8,7 +8,7 @@
import { useTeacherClassesQuery } from "@/queries/teachers";
import type { ClassesResponse } from "@/controllers/classes";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useClassesQuery, useCreateClassMutation } from "@/queries/classes";
import { useCreateClassMutation } from "@/queries/classes";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import {
useRespondTeacherInvitationMutation,
@ -16,6 +16,7 @@
} from "@/queries/teacher-invitations";
import { useDisplay } from "vuetify";
import "../../assets/common.css";
import ClassDisplay from "@/views/classes/ClassDisplay.vue";
const { t } = useI18n();
@ -41,7 +42,6 @@
// Fetch all classes of the logged in teacher
const classesQuery = useTeacherClassesQuery(username, true);
const allClassesQuery = useClassesQuery();
const { mutate } = useCreateClassMutation();
const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username);
const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation();
@ -70,7 +70,7 @@
await getInvitationsQuery.refetch();
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
});
}
@ -132,17 +132,12 @@
// Show the teacher, copying of the code was a successs
const copied = ref(false);
// Copy the generated code to the clipboard
async function copyToClipboard(): Promise<void> {
await navigator.clipboard.writeText(code.value);
copied.value = true;
}
async function copyToClipboard(code: string, isDialog = false, isLink = false): Promise<void> {
const content = isLink ? `${window.location.origin}/user/class?code=${code}` : code;
await navigator.clipboard.writeText(content);
copied.value = isDialog;
async function copyCode(selectedCode: string): Promise<void> {
code.value = selectedCode;
await copyToClipboard();
showSnackbar(t("copied"), "white");
copied.value = false;
if (!isDialog) showSnackbar(t("copied"), "white");
}
// Custom breakpoints
@ -170,6 +165,7 @@
// Code display dialog logic
const viewCodeDialog = ref(false);
const selectedCode = ref("");
function openCodeDialog(codeToView: string): void {
selectedCode.value = codeToView;
viewCodeDialog.value = true;
@ -231,24 +227,38 @@
variant="text"
>
{{ c.displayName }}
<v-icon end> mdi-menu-right </v-icon>
<v-icon end> mdi-menu-right</v-icon>
</v-btn>
</td>
<td>
<v-btn
<v-row
v-if="!isMdAndDown"
variant="text"
append-icon="mdi-content-copy"
@click="copyCode(c.id)"
dense
align="center"
no-gutters
>
{{ c.id }}
</v-btn>
<v-btn
variant="text"
append-icon="mdi-content-copy"
@click="copyToClipboard(c.id)"
>
{{ c.id }}
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(c.id, false, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</v-row>
<span
v-else
style="cursor: pointer"
@click="openCodeDialog(c.id)"
><v-icon icon="mdi-eye"></v-icon
></span>
>
<v-icon icon="mdi-eye"></v-icon>
</span>
</td>
<td>{{ c.students.length }}</td>
@ -300,8 +310,8 @@
type="submit"
@click="createClass"
block
>{{ t("create") }}</v-btn
>
>{{ t("create") }}
</v-btn>
</v-form>
</v-sheet>
<v-container>
@ -310,14 +320,29 @@
max-width="400px"
>
<v-card>
<v-card-title class="headline">code</v-card-title>
<v-card-title class="headline">{{ t("code") }}</v-card-title>
<v-card-text>
<v-text-field
v-model="code"
readonly
append-inner-icon="mdi-content-copy"
@click:append-inner="copyToClipboard"
></v-text-field>
>
<template #append>
<v-btn
icon
variant="text"
@click="copyToClipboard(code, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(code, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition>
<div
v-if="copied"
@ -368,85 +393,75 @@
:query-result="getInvitationsQuery"
v-slot="invitationsResponse: { data: TeacherInvitationsResponse }"
>
<using-query-result
:query-result="allClassesQuery"
v-slot="classesResponse: { data: ClassesResponse }"
>
<template v-if="invitationsResponse.data.invitations.length">
<tr
v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]"
:key="i.classId"
<template v-if="invitationsResponse.data.invitations.length">
<tr
v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]"
:key="i.classId"
>
<td>
<ClassDisplay :classId="i.classId" />
</td>
<td>
{{
(i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName
}}
</td>
<td class="text-right">
<span v-if="!isSmAndDown">
<div>
<v-btn
color="green"
@click="handleInvitation(i, true)"
class="mr-2"
>
{{ t("accept") }}
</v-btn>
<v-btn
color="red"
@click="handleInvitation(i, false)"
>
{{ t("deny") }}
</v-btn>
</div>
</span>
<span v-else>
<div>
<v-btn
@click="handleInvitation(i, true)"
class="mr-2"
icon="mdi-check-circle"
color="green"
variant="text"
>
</v-btn>
<v-btn
@click="handleInvitation(i, false)"
class="mr-2"
icon="mdi-close-circle"
color="red"
variant="text"
>
</v-btn>
</div>
</span>
</td>
</tr>
</template>
<template v-else>
<tr>
<td
colspan="3"
class="empty-message"
>
<td>
{{
(classesResponse.data.classes as ClassDTO[]).filter(
(c) => c.id == i.classId,
)[0].displayName
}}
</td>
<td>
{{
(i.sender as TeacherDTO).firstName +
" " +
(i.sender as TeacherDTO).lastName
}}
</td>
<td class="text-right">
<span v-if="!isSmAndDown">
<div>
<v-btn
color="green"
@click="handleInvitation(i, true)"
class="mr-2"
>
{{ t("accept") }}
</v-btn>
<v-btn
color="red"
@click="handleInvitation(i, false)"
>
{{ t("deny") }}
</v-btn>
</div>
</span>
<span v-else>
<div>
<v-btn
@click="handleInvitation(i, true)"
class="mr-2"
icon="mdi-check-circle"
color="green"
variant="text"
>
</v-btn>
<v-btn
@click="handleInvitation(i, false)"
class="mr-2"
icon="mdi-close-circle"
color="red"
variant="text"
>
</v-btn></div
></span>
</td>
</tr>
</template>
<template v-else>
<tr>
<td
colspan="3"
class="empty-message"
<v-icon
icon="mdi-information-outline"
size="small"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-invitations-found") }}
</td>
</tr>
</template>
</using-query-result>
</v-icon>
{{ t("no-invitations-found") }}
</td>
</tr>
</template>
</using-query-result>
</tbody>
</v-table>
@ -469,9 +484,24 @@
<v-text-field
v-model="selectedCode"
readonly
append-inner-icon="mdi-content-copy"
@click:append-inner="copyToClipboard"
></v-text-field>
>
<template #append>
<v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition>
<div
v-if="copied"

View file

@ -2,6 +2,7 @@
import authState from "@/services/auth/auth-service.ts";
import TeacherClasses from "./TeacherClasses.vue";
import StudentClasses from "./StudentClasses.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
// Determine if role is student or teacher to render correct view
const role: string = authState.authState.activeRole!;
@ -9,7 +10,7 @@
<template>
<main>
<TeacherClasses v-if="role === 'teacher'"></TeacherClasses>
<TeacherClasses v-if="role === AccountType.Teacher"></TeacherClasses>
<StudentClasses v-else></StudentClasses>
</main>
</template>

View file

@ -22,6 +22,7 @@
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 { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
const route = useRoute();
@ -235,8 +236,10 @@
</p>
</template>
</v-list-item>
<v-list-item
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'"
<v-list-itemF
v-if="
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
"
>
<template v-slot:default>
<learning-path-group-selector
@ -245,9 +248,9 @@
v-model="forGroupQueryParam"
/>
</template>
</v-list-item>
</v-list-itemF>
<v-divider></v-divider>
<div v-if="props.learningObjectHruid">
<div>
<using-query-result
:query-result="learningObjectListQueryResult"
v-slot="learningObjects: { data: LearningObject[] }"
@ -259,7 +262,9 @@
:title="node.title"
:active="node.key === props.learningObjectHruid"
:key="node.key"
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
v-if="
!node.teacherExclusive || authService.authState.activeRole === AccountType.Teacher
"
>
<template v-slot:prepend>
<v-icon
@ -283,7 +288,7 @@
</using-query-result>
</div>
<v-spacer></v-spacer>
<v-list-item v-if="authService.authState.activeRole === 'teacher'">
<v-list-item v-if="authService.authState.activeRole === AccountType.Teacher">
<template v-slot:default>
<v-btn
class="button-in-nav"
@ -296,7 +301,7 @@
</v-list-item>
<v-list-item>
<div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment"
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="assignment-indicator"
>
{{ t("assignmentIndicator") }}
@ -325,7 +330,7 @@
></learning-object-view>
</div>
<div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment"
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="question-box"
>
<div class="input-wrapper">

View file

@ -17,43 +17,29 @@
</script>
<template>
<v-container class="search-page-container">
<v-row
justify="center"
class="mb-6"
<div class="search-page-container d-flex flex-column align-items-center justify-center">
<div class="search-field-container">
<learning-path-search-field class="mx-auto" />
</div>
<using-query-result
:query-result="searchQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
>
<v-col
cols="12"
sm="8"
md="6"
lg="4"
>
<learning-path-search-field class="search-field" />
</v-col>
</v-row>
<learning-paths-grid :learning-paths="data" />
</using-query-result>
<v-row justify="center">
<v-col cols="12">
<using-query-result
:query-result="searchQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
>
<learning-paths-grid :learning-paths="data" />
</using-query-result>
<div
v-if="!query"
class="empty-state-container"
>
<v-empty-state
icon="mdi-magnify"
:title="t('enterSearchTerm')"
:text="t('enterSearchTermDescription')"
/>
</div>
</v-col>
</v-row>
</v-container>
<div
v-if="!query"
class="empty-state-container"
>
<v-empty-state
icon="mdi-magnify"
:title="t('enterSearchTerm')"
:text="t('enterSearchTermDescription')"
/>
</div>
</div>
</template>
<style scoped>
@ -61,8 +47,7 @@
padding-top: 40px;
padding-bottom: 40px;
}
.search-field {
max-width: 100%;
.search-field-container {
justify-content: center !important;
}
</style>

View file

@ -8,8 +8,9 @@
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue";
import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const _isStudent = computed(() => authService.authState.activeRole === "student");
const _isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const props = defineProps<{
hruid: string;

View file

@ -11,6 +11,7 @@
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types";
const { t } = useI18n();
@ -31,7 +32,7 @@
mutate: submitSolution,
} = useCreateSubmissionMutation();
const isStudent = computed(() => authService.authState.activeRole === "student");
const isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const isSubmitDisabled = computed(() => {
if (!props.submissionData || props.submissions === undefined) {

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { useLearningObjectListForAdminQuery } from "@/queries/learning-objects.ts";
import OwnLearningObjectsView from "@/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue";
import OwnLearningPathsView from "@/views/own-learning-content/learning-paths/OwnLearningPathsView.vue";
import authService from "@/services/auth/auth-service.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
import { ref, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { useGetAllLearningPathsByAdminQuery } from "@/queries/learning-paths";
import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content";
const { t } = useI18n();
const learningObjectsQuery = useLearningObjectListForAdminQuery(
authService.authState.user?.profile.preferred_username,
);
const learningPathsQuery = useGetAllLearningPathsByAdminQuery(
authService.authState.user?.profile.preferred_username,
);
type Tab = "learningObjects" | "learningPaths";
const tab: Ref<Tab> = ref("learningObjects");
</script>
<template>
<div class="tab-pane-container">
<h1 class="title">{{ t("ownLearningContentTitle") }}</h1>
<v-tabs v-model="tab">
<v-tab value="learningObjects">{{ t("learningObjects") }}</v-tab>
<v-tab value="learningPaths">{{ t("learningPaths") }}</v-tab>
</v-tabs>
<v-tabs-window
v-model="tab"
class="main-content"
>
<v-tabs-window-item
value="learningObjects"
class="main-content"
>
<using-query-result
:query-result="learningObjectsQuery"
v-slot="response: { data: LearningObject[] }"
>
<own-learning-objects-view :learningObjects="response.data"></own-learning-objects-view>
</using-query-result>
</v-tabs-window-item>
<v-tabs-window-item value="learningPaths">
<using-query-result
:query-result="learningPathsQuery"
v-slot="response: { data: LearningPathDTO[] }"
>
<own-learning-paths-view :learningPaths="response.data" />
</using-query-result>
</v-tabs-window-item>
</v-tabs-window>
</div>
</template>
<style scoped>
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
font-size: 50px;
}
.tab-pane-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px 30px;
}
.main-content {
flex: 1 1;
height: 100%;
}
</style>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import LearningObjectContentView from "../../learning-paths/learning-object/content/LearningObjectContentView.vue";
import ButtonWithConfirmation from "@/components/ButtonWithConfirmation.vue";
import { useDeleteLearningObjectMutation, useLearningObjectHTMLQuery } from "@/queries/learning-objects";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
selectedLearningObject?: LearningObject;
}>();
const learningObjectQueryResult = useLearningObjectHTMLQuery(
() => props.selectedLearningObject?.key,
() => props.selectedLearningObject?.language,
() => props.selectedLearningObject?.version,
);
const { isPending, mutate } = useDeleteLearningObjectMutation();
function deleteLearningObject(): void {
if (props.selectedLearningObject) {
mutate({
hruid: props.selectedLearningObject.key,
language: props.selectedLearningObject.language,
version: props.selectedLearningObject.version,
});
}
}
</script>
<template>
<v-card
v-if="selectedLearningObject"
:title="t('previewFor') + selectedLearningObject.title"
>
<template v-slot:text>
<using-query-result
:query-result="learningObjectQueryResult"
v-slot="response: { data: Document }"
>
<learning-object-content-view :learning-object-content="response.data"></learning-object-content-view>
</using-query-result>
</template>
<template v-slot:actions>
<button-with-confirmation
@confirm="deleteLearningObject"
prepend-icon="mdi mdi-delete"
color="red"
:text="t('delete')"
:confirmQueryText="t('learningObjectDeleteQuery')"
/>
</template>
</v-card>
</template>
<style scoped></style>

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import { useUploadLearningObjectMutation } from "@/queries/learning-objects";
import { ref, watch, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { VFileUpload } from "vuetify/labs/VFileUpload";
const { t } = useI18n();
const dialogOpen = ref(false);
interface ContainsErrorString {
error: string;
}
const fileToUpload: Ref<File | undefined> = ref(undefined);
const { isPending, error, isError, isSuccess, mutate } = useUploadLearningObjectMutation();
watch(isSuccess, (newIsSuccess) => {
if (newIsSuccess) {
dialogOpen.value = false;
fileToUpload.value = undefined;
}
});
function uploadFile() {
if (fileToUpload.value) {
mutate({ learningObjectZip: fileToUpload.value });
}
}
</script>
<template>
<v-dialog
max-width="500"
v-model="dialogOpen"
>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
prepend-icon="mdi mdi-plus"
:text="t('newLearningObject')"
v-bind="activatorProps"
color="rgb(14, 105, 66)"
size="large"
>
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card :title="t('learningObjectUploadTitle')">
<v-card-text>
<v-file-upload
icon="mdi-upload"
v-model="fileToUpload"
:disabled="isPending"
></v-file-upload>
<v-alert
v-if="error"
icon="mdi mdi-alert-circle"
type="error"
:title="t('uploadFailed')"
:text="t((error.response?.data as ContainsErrorString).error ?? error.message)"
></v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:text="t('cancel')"
@click="isActive.value = false"
></v-btn>
<v-btn
:text="t('upload')"
@click="uploadFile()"
:loading="isPending"
:disabled="!fileToUpload"
></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<style scoped></style>

View file

@ -0,0 +1,79 @@
<script setup lang="ts">
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
import LearningObjectUploadButton from "@/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue";
import LearningObjectPreviewCard from "./LearningObjectPreviewCard.vue";
import { computed, ref, watch, type Ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
learningObjects: LearningObject[];
}>();
const tableHeaders = [
{ title: t("hruid"), width: "250px", key: "key" },
{ title: t("language"), width: "50px", key: "language" },
{ title: t("version"), width: "50px", key: "version" },
{ title: t("title"), key: "title" },
];
const selectedLearningObjects: Ref<LearningObject[]> = ref([]);
watch(
() => props.learningObjects,
() => (selectedLearningObjects.value = []),
);
const selectedLearningObject = computed(() =>
selectedLearningObjects.value ? selectedLearningObjects.value[0] : undefined,
);
</script>
<template>
<div class="root">
<div class="table-container">
<learning-object-upload-button />
<v-data-table
class="table"
v-model="selectedLearningObjects"
:items="props.learningObjects"
:headers="tableHeaders"
select-strategy="single"
show-select
return-object
/>
</div>
<div
class="preview-container"
v-if="selectedLearningObject"
>
<learning-object-preview-card
class="preview"
:selectedLearningObject="selectedLearningObject"
/>
</div>
</div>
</template>
<style scoped>
.root {
display: flex;
gap: 20px;
padding: 20px;
flex-wrap: wrap;
}
.preview-container {
flex: 1;
min-width: 400px;
}
.table-container {
flex: 1;
}
.preview {
width: 100%;
}
.table {
width: 100%;
margin-top: 20px;
}
</style>

View file

@ -0,0 +1,147 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { computed, ref, watch, type Ref } from "vue";
import JsonEditorVue from "json-editor-vue";
import ButtonWithConfirmation from "@/components/ButtonWithConfirmation.vue";
import {
useDeleteLearningPathMutation,
usePostLearningPathMutation,
usePutLearningPathMutation,
} from "@/queries/learning-paths";
import { Language } from "@/data-objects/language";
import type { LearningPath } from "@dwengo-1/common/interfaces/learning-content";
import type { AxiosError } from "axios";
import { parse } from "uuid";
const { t } = useI18n();
const props = defineProps<{
selectedLearningPath?: LearningPath;
}>();
const { isPending, mutate, error: deleteError, isSuccess: deleteSuccess } = useDeleteLearningPathMutation();
const DEFAULT_LEARNING_PATH: Partial<LearningPath> = {
language: "en",
hruid: "...",
title: "...",
description: "...",
nodes: [
{
learningobject_hruid: "...",
language: Language.English,
version: 1,
start_node: true,
transitions: [
{
default: true,
condition: t("hintRemoveIfUnconditionalTransition"),
next: {
hruid: "...",
version: 1,
language: "...",
},
},
],
},
],
};
const { isPending: isPostPending, error: postError, mutate: doPost } = usePostLearningPathMutation();
const { isPending: isPutPending, error: putError, mutate: doPut } = usePutLearningPathMutation();
const learningPath: Ref<Partial<LearningPath> | string> = ref(DEFAULT_LEARNING_PATH);
const parsedLearningPath = computed(() =>
typeof learningPath.value === "string" ? (JSON.parse(learningPath.value) as LearningPath) : learningPath.value,
);
watch(
() => props.selectedLearningPath,
() => (learningPath.value = props.selectedLearningPath ?? DEFAULT_LEARNING_PATH),
);
function uploadLearningPath(): void {
if (props.selectedLearningPath) {
doPut({ learningPath: parsedLearningPath.value });
} else {
doPost({ learningPath: parsedLearningPath.value });
}
}
function deleteLearningPath(): void {
if (props.selectedLearningPath) {
mutate({
hruid: props.selectedLearningPath.hruid,
language: props.selectedLearningPath.language as Language,
});
}
}
function extractErrorMessage(error: AxiosError): string {
return (error.response?.data as { error: string }).error ?? error.message;
}
const isIdModified = computed(
() =>
props.selectedLearningPath !== undefined &&
(props.selectedLearningPath.hruid !== parsedLearningPath.value.hruid ||
props.selectedLearningPath.language !== parsedLearningPath.value.language),
);
function getErrorMessage(): string | null {
if (postError.value) {
return t(extractErrorMessage(postError.value));
} else if (putError.value) {
return t(extractErrorMessage(putError.value));
} else if (deleteError.value) {
return t(extractErrorMessage(deleteError.value));
} else if (isIdModified.value) {
return t("learningPathCantModifyId");
}
return null;
}
</script>
<template>
<v-card :title="props.selectedLearningPath ? t('editLearningPath') : t('newLearningPath')">
<template v-slot:text>
<json-editor-vue v-model="learningPath"></json-editor-vue>
<v-alert
v-if="postError || putError || deleteError || isIdModified"
icon="mdi mdi-alert-circle"
type="error"
:title="t('error')"
:text="getErrorMessage()!"
></v-alert>
</template>
<template v-slot:actions>
<v-btn
@click="uploadLearningPath"
prependIcon="mdi mdi-check"
:loading="isPostPending || isPutPending"
:disabled="parsedLearningPath.hruid === DEFAULT_LEARNING_PATH.hruid || isIdModified"
>
{{ props.selectedLearningPath ? t("saveChanges") : t("create") }}
</v-btn>
<button-with-confirmation
@confirm="deleteLearningPath"
:disabled="!props.selectedLearningPath"
:text="t('delete')"
color="red"
prependIcon="mdi mdi-delete"
:confirmQueryText="t('learningPathDeleteQuery')"
/>
<v-btn
:href="`/learningPath/${props.selectedLearningPath?.hruid}/${props.selectedLearningPath?.language}/start`"
target="_blank"
prepend-icon="mdi mdi-open-in-new"
:disabled="!props.selectedLearningPath"
>
{{ t("open") }}
</v-btn>
</template>
</v-card>
</template>
<style scoped></style>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import LearningPathPreviewCard from "./LearningPathPreviewCard.vue";
import { computed, ref, watch, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content";
const { t } = useI18n();
const props = defineProps<{
learningPaths: LearningPathDTO[];
}>();
const tableHeaders = [
{ title: t("hruid"), width: "250px", key: "hruid" },
{ title: t("language"), width: "50px", key: "language" },
{ title: t("title"), key: "title" },
];
const selectedLearningPaths: Ref<LearningPathDTO[]> = ref([]);
const selectedLearningPath = computed(() =>
selectedLearningPaths.value ? selectedLearningPaths.value[0] : undefined,
);
watch(
() => props.learningPaths,
() => (selectedLearningPaths.value = []),
);
</script>
<template>
<div class="root">
<div class="table-container">
<v-data-table
class="table"
v-model="selectedLearningPaths"
:items="props.learningPaths"
:headers="tableHeaders"
select-strategy="single"
show-select
return-object
/>
</div>
<div class="preview-container">
<learning-path-preview-card
class="preview"
:selectedLearningPath="selectedLearningPath"
/>
</div>
</div>
</template>
<style scoped>
.fab {
position: absolute;
right: 20px;
bottom: 20px;
}
.root {
display: flex;
gap: 20px;
padding: 20px;
flex-wrap: wrap;
}
.preview-container {
flex: 1;
min-width: 400px;
}
.table-container {
flex: 1;
}
.preview {
width: 100%;
}
.table {
width: 100%;
}
</style>