Calc next learning content

This commit is contained in:
Daniel Egger 2023-10-13 17:40:46 +02:00
parent e5cc0aa80e
commit cc800501c1
26 changed files with 393 additions and 968 deletions

View File

@ -1,6 +1,10 @@
import { graphqlClient } from "@/graphql/client"; import { graphqlClient } from "@/graphql/client";
import { COURSE_QUERY, COURSE_SESSION_DETAIL_QUERY } from "@/graphql/queries"; import { COURSE_QUERY, COURSE_SESSION_DETAIL_QUERY } from "@/graphql/queries";
import { circleFlatChildren, circleFlatLearningContents } from "@/services/circle"; import {
circleFlatChildren,
circleFlatLearningContents,
circleFlatLearningUnits,
} from "@/services/circle";
import { useCompletionStore } from "@/stores/completion"; import { useCompletionStore } from "@/stores/completion";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
@ -16,6 +20,7 @@ import type {
PerformanceCriteria, PerformanceCriteria,
} from "@/types"; } from "@/types";
import { useQuery } from "@urql/vue"; import { useQuery } from "@urql/vue";
import orderBy from "lodash/orderBy";
import log from "loglevel"; import log from "loglevel";
import type { ComputedRef } from "vue"; import type { ComputedRef } from "vue";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
@ -157,25 +162,35 @@ export function useLearningPath(courseSlug: string) {
// attach circle information to learning contents // attach circle information to learning contents
if (learningPath.value) { if (learningPath.value) {
flatCircles(learningPath.value).forEach((circle) => { flatCircles(learningPath.value).forEach((circle) => {
circleFlatChildren(circle).forEach((lc) => { circle.learning_sequences.forEach((ls, lsIndex) => {
lc.circle = { const circleData = {
id: circle.id, id: circle.id,
slug: circle.slug, slug: circle.slug,
title: circle.title, title: circle.title,
}; };
return ls.learning_units.forEach((lu, luIndex) => {
lu.circle = Object.assign({}, circleData);
lu.learning_contents.forEach((lc, lcIndex) => {
lc.circle = Object.assign({}, circleData);
lc.continueUrl = ls.frontend_url || circle.frontend_url;
lc.firstInCircle = lcIndex === 0 && luIndex === 0 && lsIndex === 0;
lc.parentLearningUnit = {
id: lu.id,
slug: lu.slug,
title: lu.title,
};
});
if (lc.content_type === "competence.PerformanceCriteria") { lu.performance_criteria.forEach((luPc) => {
const pc = findPerformanceCriterion(lc.id); luPc.circle = Object.assign({}, circleData);
const pc = findPerformanceCriterion(luPc.id);
if (pc) { if (pc) {
pc.circle = { pc.circle = Object.assign({}, circleData);
id: circle.id,
slug: circle.slug,
title: circle.title,
};
}
} }
}); });
}); });
});
});
} }
}); });
@ -186,9 +201,9 @@ export function useLearningPath(courseSlug: string) {
return undefined; return undefined;
}); });
function findCircle(slug: string) { function findCircle(idOrSlug: string) {
return (circles.value ?? []).find((c) => { return (circles.value ?? []).find((c) => {
return c.slug.endsWith(slug); return c.id === idOrSlug || c.slug.endsWith(idOrSlug);
}); });
} }
@ -202,13 +217,42 @@ export function useLearningPath(courseSlug: string) {
}) as PerformanceCriteria | undefined; }) as PerformanceCriteria | undefined;
} }
function findLearningContent(learningContentId: string) { function findLearningContent(
return (circles.value ?? []) learningContentIdOrSlug: string,
circleIdOrSlug?: string
) {
let filteredCircles = circles.value ?? [];
if (circleIdOrSlug) {
filteredCircles = filteredCircles.filter((c) => {
return c.id === circleIdOrSlug || c.slug.endsWith(circleIdOrSlug);
});
}
return filteredCircles
.flatMap((c) => { .flatMap((c) => {
return circleFlatLearningContents(c); return circleFlatLearningContents(c);
}) })
.find((lc) => { .find((lc) => {
return lc.id === learningContentId; return (
lc.id === learningContentIdOrSlug || lc.slug.endsWith(learningContentIdOrSlug)
);
});
}
function findLearningUnit(learningUnitIdOrSlug: string, circleIdOrSlug?: string) {
let filteredCircles = circles.value ?? [];
if (circleIdOrSlug) {
filteredCircles = filteredCircles.filter((c) => {
return c.id === circleIdOrSlug || c.slug.endsWith(circleIdOrSlug);
});
}
return filteredCircles
.flatMap((c) => {
return circleFlatLearningUnits(c);
})
.find((lu) => {
return lu.id === learningUnitIdOrSlug || lu.slug.endsWith(learningUnitIdOrSlug);
}); });
} }
@ -225,6 +269,7 @@ export function useLearningPath(courseSlug: string) {
circles, circles,
findCircle, findCircle,
findLearningContent, findLearningContent,
findLearningUnit,
flatPerformanceCriteria, flatPerformanceCriteria,
}; };
} }
@ -246,7 +291,7 @@ export function useLearningPathWithCompletion(
const courseResult = useLearningPath(courseSlug); const courseResult = useLearningPath(courseSlug);
const completionStore = useCompletionStore(); const completionStore = useCompletionStore();
const nextLearningContent = ref<LearningContentWithCompletion | null>(null); const nextLearningContent = ref<LearningContentWithCompletion | undefined>(undefined);
const loaded = ref(false); const loaded = ref(false);
function updateCompletionData() { function updateCompletionData() {
@ -287,11 +332,37 @@ export function useLearningPathWithCompletion(
}); });
} }
// FIXME calculate nextLearningContent calcNextLearningContent(completionData);
if (courseResult.circles.value?.length) { }
nextLearningContent.value = circleFlatLearningContents(
courseResult.circles.value[0] function calcNextLearningContent(completionData: CourseCompletion[]) {
)[0]; const lastCompleted = findLastCompletedLearningContent(completionData);
if (lastCompleted) {
const flatLearningContents = (courseResult.circles.value ?? []).flatMap((c) => {
return circleFlatLearningContents(c);
});
const lastCompletedIndex = flatLearningContents.findIndex((lc) => {
return lc.id === lastCompleted.id;
});
if (flatLearningContents[lastCompletedIndex + 1]) {
nextLearningContent.value = flatLearningContents[lastCompletedIndex + 1];
} else {
nextLearningContent.value = undefined;
}
}
}
function findLastCompletedLearningContent(completionData: CourseCompletion[]) {
const latestCompletion = orderBy(completionData ?? [], ["updated_at"], "desc").find(
(c: CourseCompletion) => {
return (
c.completion_status === "SUCCESS" &&
c.page_type.startsWith("learnpath.LearningContent")
);
}
);
if (latestCompletion) {
return courseResult.findLearningContent(latestCompletion.page_id);
} }
} }
@ -326,7 +397,6 @@ export function useLearningPathWithCompletion(
...courseResult, ...courseResult,
loaded, loaded,
resultPromise, resultPromise,
updateCompletionData,
markCompletion, markCompletion,
nextLearningContent, nextLearningContent,
}; };

View File

@ -1,9 +1,3 @@
import type { CourseCompletionStatus } from "@/types";
export const COMPLETION_SUCCESS: CourseCompletionStatus = "SUCCESS";
export const COMPLETION_FAILURE: CourseCompletionStatus = "FAIL";
export const COMPLETION_UNKNOWN: CourseCompletionStatus = "UNKNOWN";
export const itCheckboxDefaultIconCheckedTailwindClass = export const itCheckboxDefaultIconCheckedTailwindClass =
"bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]"; "bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]";

View File

@ -71,6 +71,7 @@ function setActiveClasses(isActive: boolean) {
:use-mobile-layout="false" :use-mobile-layout="false"
:hide-buttons="true" :hide-buttons="true"
:learning-path="learningPath" :learning-path="learningPath"
:next-learning-content="undefined"
:override-circle-url-base="`/course/${props.courseSlug}/cockpit/profile/${props.userId}`" :override-circle-url-base="`/course/${props.courseSlug}/cockpit/profile/${props.userId}`"
></LearningPathPathView> ></LearningPathPathView>
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession, useLearningPath } from "@/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue"; import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useCockpitStore } from "@/stores/cockpit"; import { useCockpitStore } from "@/stores/cockpit";
import ItModal from "@/components/ui/ItModal.vue"; import ItModal from "@/components/ui/ItModal.vue";
@ -16,13 +16,11 @@ import {
} from "@/services/files"; } from "@/services/files";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import DocumentListItem from "@/components/circle/DocumentListItem.vue"; import DocumentListItem from "@/components/circle/DocumentListItem.vue";
import { useCircleStore } from "@/stores/circle";
const cockpitStore = useCockpitStore(); const cockpitStore = useCockpitStore();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const circleStore = useCircleStore();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const courseData = useLearningPath(courseSession.value?.course.slug);
const { t } = useTranslation(); const { t } = useTranslation();
@ -51,26 +49,18 @@ onMounted(async () => {
await fetchDocuments(); await fetchDocuments();
}); });
watch( const dropdownLearningSequences = computed(() => {
// workaround to load learning sequences when circle changes if (cockpitStore.currentCircle?.slug) {
() => cockpitStore.currentCircle, const circle = courseData.findCircle(cockpitStore.currentCircle?.slug);
async () => { if (circle) {
if (cockpitStore.currentCircle) { return circle.learning_sequences.map((sequence) => ({
await circleStore.loadCircle(
courseSession.value?.course.slug,
cockpitStore.currentCircle?.slug
);
}
},
{ immediate: true }
);
const dropdownLearningSequences = computed(() =>
circleStore.circle?.learningSequences.map((sequence) => ({
id: sequence.id, id: sequence.id,
name: `${sequence.title}`, name: `${sequence.title}`,
})) }));
); }
}
return [];
});
const circleDocuments = computed(() => { const circleDocuments = computed(() => {
return circleDocumentsResultData.value.filter( return circleDocumentsResultData.value.filter(

View File

@ -189,7 +189,7 @@ watch(
{{ $t("circlePage.learnMore") }} {{ $t("circlePage.learnMore") }}
</button> </button>
</div> </div>
<DocumentSection v-if="!readonly" /> <DocumentSection v-if="!readonly" :circle="circle" />
<div v-if="!props.readonly" class="expert mt-8 border p-6"> <div v-if="!props.readonly" class="expert mt-8 border p-6">
<h3 class="text-blue-dark">{{ $t("circlePage.gotQuestions") }}</h3> <h3 class="text-blue-dark">{{ $t("circlePage.gotQuestions") }}</h3>
<div class="mt-4 leading-relaxed"> <div class="mt-4 leading-relaxed">
@ -228,6 +228,7 @@ watch(
> >
<LearningSequence <LearningSequence
:course-slug="props.courseSlug" :course-slug="props.courseSlug"
:circle="circle"
:learning-sequence="learningSequence" :learning-sequence="learningSequence"
:readonly="props.readonly" :readonly="props.readonly"
></LearningSequence> ></LearningSequence>

View File

@ -23,12 +23,14 @@
import DocumentListItem from "@/components/circle/DocumentListItem.vue"; import DocumentListItem from "@/components/circle/DocumentListItem.vue";
import { useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession } from "@/composables";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useCircleStore } from "@/stores/circle"; import type { CircleDocument, CircleType } from "@/types";
import type { CircleDocument } from "@/types";
import { fetchCourseSessionDocuments } from "@/services/files"; import { fetchCourseSessionDocuments } from "@/services/files";
const props = defineProps<{
circle: CircleType;
}>();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const circleStore = useCircleStore();
const circleDocumentsResultData = ref<CircleDocument[]>([]); const circleDocumentsResultData = ref<CircleDocument[]>([]);
@ -43,7 +45,7 @@ async function fetchDocuments() {
const circleDocuments = computed(() => { const circleDocuments = computed(() => {
return circleDocumentsResultData.value.filter( return circleDocumentsResultData.value.filter(
(d) => d.learning_sequence.circle.slug === circleStore.circle?.slug (d) => d.learning_sequence.circle.slug === props.circle?.slug
); );
}); });

View File

@ -3,6 +3,7 @@ import LearningContentBadge from "@/pages/learningPath/LearningContentTypeBadge.
import { showIcon } from "@/pages/learningPath/circlePage/learningSequenceUtils"; import { showIcon } from "@/pages/learningPath/circlePage/learningSequenceUtils";
import { useCircleStore } from "@/stores/circle"; import { useCircleStore } from "@/stores/circle";
import type { import type {
CircleType,
CourseCompletionStatus, CourseCompletionStatus,
LearningContent, LearningContent,
LearningContentAssignment, LearningContentAssignment,
@ -10,6 +11,7 @@ import type {
LearningContentWithCompletion, LearningContentWithCompletion,
LearningSequence, LearningSequence,
} from "@/types"; } from "@/types";
import type { Ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { import {
itCheckboxDefaultIconCheckedTailwindClass, itCheckboxDefaultIconCheckedTailwindClass,
@ -19,13 +21,16 @@ import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { import {
allFinishedInLearningSequence, allFinishedInLearningSequence,
calcSelfEvaluationStatus, calcSelfEvaluationStatus,
circleFlatLearningContents,
someFinishedInLearningSequence, someFinishedInLearningSequence,
} from "@/services/circle"; } from "@/services/circle";
import { useLearningPathWithCompletion } from "@/composables"; import { useLearningPathWithCompletion } from "@/composables";
import { findLastIndex } from "lodash";
type Props = { type Props = {
courseSlug: string; courseSlug: string;
learningSequence: LearningSequence; learningSequence: LearningSequence;
circle: CircleType;
readonly?: boolean; readonly?: boolean;
}; };
@ -61,26 +66,27 @@ const allFinished = computed(() => {
return false; return false;
}); });
const continueTranslationKeyTuple = computed(() => { const continueTranslationKeyTuple: Ref<[string | undefined, boolean]> = computed(() => {
// if (props.learningSequence && circleStore.circle) { if (props.learningSequence) {
// const lastFinished = findLast( const flatLearningContents = circleFlatLearningContents(props.circle);
// circleStore.circle.flatLearningContents, const lastFinishedIndex = findLastIndex(
// (learningContent) => { circleFlatLearningContents(props.circle),
// return learningContent.completion_status === "SUCCESS"; (learningContent) => {
// } return learningContent.completion_status === "SUCCESS";
// ); }
// );
// if (!lastFinished) {
// // must be the first
// return [circleStore.circle.flatLearningContents[0].translation_key, true];
// }
//
// if (lastFinished && lastFinished.nextLearningContent) {
// return [lastFinished.nextLearningContent.translation_key, false];
// }
// }
return ""; if (lastFinishedIndex === -1) {
// must be the first
return [flatLearningContents[0].id, true];
}
if (flatLearningContents[lastFinishedIndex + 1]) {
return [flatLearningContents[lastFinishedIndex + 1].id, false];
}
}
return [undefined, false];
}); });
const learningSequenceBorderClass = computed(() => { const learningSequenceBorderClass = computed(() => {
@ -100,8 +106,8 @@ const learningSequenceBorderClass = computed(() => {
function belongsToCompetenceCertificate(lc: LearningContent) { function belongsToCompetenceCertificate(lc: LearningContent) {
return ( return (
(lc.__typename === "LearningContentAssignmentObjectType" || (lc.content_type === "learnpath.LearningContentAssignment" ||
lc.__typename === "LearningContentEdoniqTestObjectType") && lc.content_type === "learnpath.LearningContentEdoniqTest") &&
lc.competence_certificate?.frontend_url lc.competence_certificate?.frontend_url
); );
} }
@ -239,7 +245,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
<div <div
v-if=" v-if="
learningContent.translation_key === continueTranslationKeyTuple[0] && learningContent.id === continueTranslationKeyTuple[0] &&
!props.readonly !props.readonly
" "
class="my-4" class="my-4"
@ -287,7 +293,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
</div> </div>
</div> </div>
<hr v-if="!learningUnit.last" class="-mx-4 text-gray-500" /> <!-- <hr v-if="!learningUnit.last" class="-mx-4 text-gray-500" />-->
</li> </li>
</ol> </ol>
</div> </div>

View File

@ -1,12 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import LearningContentParent from "@/pages/learningPath/learningContentPage/LearningContentParent.vue"; import LearningContentParent from "@/pages/learningPath/learningContentPage/LearningContentParent.vue";
import { useCircleStore } from "@/stores/circle";
import type { LearningContent } from "@/types";
import * as log from "loglevel"; import * as log from "loglevel";
import type { Ref } from "vue"; import { computed, getCurrentInstance, onUpdated } from "vue";
import { getCurrentInstance, onMounted, onUpdated, ref, watch } from "vue"; import { useLearningPathWithCompletion } from "@/composables";
import { stringifyParse } from "@/utils/utils";
log.debug("LearningContentView created");
const props = defineProps<{ const props = defineProps<{
courseSlug: string; courseSlug: string;
@ -14,43 +11,14 @@ const props = defineProps<{
contentSlug: string; contentSlug: string;
}>(); }>();
const learningContent: Ref<LearningContent | undefined> = ref(undefined); log.debug("LearningContentView created", stringifyParse(props));
const circleStore = useCircleStore(); const courseData = useLearningPathWithCompletion(props.courseSlug);
const learningContent = computed(() =>
const loadLearningContent = async () => { courseData.findLearningContent(props.contentSlug, props.circleSlug)
try {
learningContent.value = await circleStore.loadLearningContent(
props.courseSlug,
props.circleSlug,
props.contentSlug
); );
} catch (error) { const circle = computed(() => {
log.error(error); return courseData.findCircle(props.circleSlug);
}
};
watch(
() => props.contentSlug,
async () => {
log.debug(
"LearningContentView props.contentSlug changed",
props.courseSlug,
props.circleSlug,
props.contentSlug
);
await loadLearningContent();
}
);
onMounted(async () => {
log.debug(
"LearningContentView mounted",
props.courseSlug,
props.circleSlug,
props.contentSlug
);
await loadLearningContent();
}); });
onUpdated(() => { onUpdated(() => {
@ -73,7 +41,11 @@ onUpdated(() => {
</script> </script>
<template> <template>
<LearningContentParent v-if="learningContent" :learning-content="learningContent" /> <LearningContentParent
v-if="learningContent && circle"
:learning-content="learningContent"
:circle="circle"
/>
</template> </template>
<style lang="postcss" scoped></style> <style lang="postcss" scoped></style>

View File

@ -2,8 +2,12 @@
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue"; import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import DocumentListBlock from "@/pages/learningPath/learningContentPage/blocks/DocumentListBlock.vue"; import DocumentListBlock from "@/pages/learningPath/learningContentPage/blocks/DocumentListBlock.vue";
import EdoniqTestBlock from "@/pages/learningPath/learningContentPage/blocks/EdoniqTestBlock.vue"; import EdoniqTestBlock from "@/pages/learningPath/learningContentPage/blocks/EdoniqTestBlock.vue";
import { useCircleStore } from "@/stores/circle"; import type {
import type { LearningContent, LearningContentContentType } from "@/types"; CircleType,
LearningContent,
LearningContentContentType,
LearningContentWithCompletion,
} from "@/types";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
import log from "loglevel"; import log from "loglevel";
import type { Component } from "vue"; import type { Component } from "vue";
@ -17,14 +21,19 @@ import PlaceholderBlock from "./blocks/PlaceholderBlock.vue";
import RichTextBlock from "./blocks/RichTextBlock.vue"; import RichTextBlock from "./blocks/RichTextBlock.vue";
import VideoBlock from "./blocks/VideoBlock.vue"; import VideoBlock from "./blocks/VideoBlock.vue";
import { getPreviousRoute } from "@/router/history"; import { getPreviousRoute } from "@/router/history";
import { stringifyParse } from "@/utils/utils";
const circleStore = useCircleStore(); import { useLearningPathWithCompletion } from "@/composables";
import { useCircleStore } from "@/stores/circle";
const props = defineProps<{ const props = defineProps<{
learningContent: LearningContent; learningContent: LearningContent;
circle: CircleType;
}>(); }>();
log.debug("LearningContentParent setup", props.learningContent); log.debug("LearningContentParent setup", stringifyParse(props));
const courseCompletionData = useLearningPathWithCompletion();
const circleStore = useCircleStore();
const previousRoute = getPreviousRoute(); const previousRoute = getPreviousRoute();
@ -48,7 +57,14 @@ const component = computed(() => {
}); });
function handleFinishedLearningContent() { function handleFinishedLearningContent() {
circleStore.continueFromLearningContent(props.learningContent, previousRoute); circleStore.continueFromLearningContent(
props.learningContent,
props.circle,
previousRoute,
(lc: LearningContentWithCompletion) => {
courseCompletionData.markCompletion(lc, "SUCCESS");
}
);
} }
eventBus.on("finishedLearningContent", handleFinishedLearningContent); eventBus.on("finishedLearningContent", handleFinishedLearningContent);
@ -60,7 +76,9 @@ onUnmounted(() => {
<template> <template>
<LearningContentContainer <LearningContentContainer
@exit="circleStore.closeLearningContent(props.learningContent, previousRoute)" @exit="
circleStore.closeLearningContent(props.learningContent, circle, previousRoute)
"
> >
<div> <div>
<component :is="component" :content="learningContent"></component> <component :is="component" :content="learningContent"></component>

View File

@ -3,7 +3,11 @@ import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
import ItButton from "@/components/ui/ItButton.vue"; import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue"; import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue"; import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables"; import {
useCourseSessionDetailQuery,
useCurrentCourseSession,
useLearningPath,
} from "@/composables";
import { bustItGetCache } from "@/fetchHelpers"; import { bustItGetCache } from "@/fetchHelpers";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations"; import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue"; import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
@ -15,7 +19,6 @@ import { computed, reactive } from "vue";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useCircleStore } from "@/stores/circle";
const props = defineProps<{ const props = defineProps<{
assignment: Assignment; assignment: Assignment;
@ -31,7 +34,7 @@ const emit = defineEmits<{
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const courseSessionDetailResult = useCourseSessionDetailQuery(); const courseSessionDetailResult = useCourseSessionDetailQuery();
const circleStore = useCircleStore(); const courseData = useLearningPath(courseSession.value.course.slug);
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,9 +43,15 @@ const state = reactive({
confirmPerson: false, confirmPerson: false,
}); });
const learningContent = computed(() => {
return courseData.findLearningContent(props.learningContentId);
});
const circleExperts = computed(() => { const circleExperts = computed(() => {
if (circleStore.circle) { if (learningContent.value?.circle) {
return courseSessionDetailResult.filterCircleExperts(circleStore.circle.slug); return courseSessionDetailResult.filterCircleExperts(
learningContent.value?.circle.slug
);
} }
return []; return [];
}); });

View File

@ -122,7 +122,7 @@ const currentTask = computed(() => {
const initUpsertAssignmentCompletion = async () => { const initUpsertAssignmentCompletion = async () => {
try { try {
await upsertAssignmentCompletionMutation.executeMutation({ await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.learningContent.content_assignment_id, assignmentId: props.learningContent.content_assignment.id,
courseSessionId: courseSession.value.id, courseSessionId: courseSession.value.id,
learningContentId: props.learningContent.id, learningContentId: props.learningContent.id,
completionDataString: JSON.stringify({}), completionDataString: JSON.stringify({}),
@ -234,7 +234,7 @@ const assignmentUser = computed(() => {
<AssignmentTaskView <AssignmentTaskView
v-else-if="currentTask" v-else-if="currentTask"
:task="currentTask" :task="currentTask"
:assignment-id="props.learningContent.content_assignment_id" :assignment-id="props.learningContent.content_assignment.id"
:assignment-completion="assignmentCompletion" :assignment-completion="assignmentCompletion"
:learning-content-id="props.learningContent.id" :learning-content-id="props.learningContent.id"
></AssignmentTaskView> ></AssignmentTaskView>

View File

@ -2,34 +2,44 @@
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue"; import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import LearningPathContinueButton from "@/pages/learningPath/learningPathPage/LearningPathContinueButton.vue"; import LearningPathContinueButton from "@/pages/learningPath/learningPathPage/LearningPathContinueButton.vue";
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils"; import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import type { OldCircle } from "@/services/oldCircle"; import type { CircleType, LearningContentWithCompletion } from "@/types";
import type { LearningPath } from "@/services/learningPath"; import { computed, ref, watch } from "vue";
import type { Topic } from "@/types";
import { onMounted, ref } from "vue";
const props = defineProps<{ const props = defineProps<{
learningPath: LearningPath | undefined; circle: CircleType;
circle: OldCircle; nextLearningContent: LearningContentWithCompletion | undefined;
topic: Topic;
isFirstCircle: boolean; isFirstCircle: boolean;
isLastCircle: boolean; isLastCircle: boolean;
isCurrentCircle: boolean;
overrideCircleUrl?: string; overrideCircleUrl?: string;
}>(); }>();
const circleElement = ref<HTMLElement | null>(null); const circleElement = ref<HTMLElement | null>(null);
onMounted(() => { const isCurrentCircle = computed(() => {
if (props.isCurrentCircle) { return props.nextLearningContent?.circle?.id === props.circle.id;
});
function scrollToCircle() {
if (isCurrentCircle.value) {
setTimeout(() => { setTimeout(() => {
circleElement?.value?.scrollIntoView({ circleElement?.value?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
inline: "center", inline: "center",
block: "nearest", block: "nearest",
}); });
}, 400);
}
}); });
}
}
watch(
() => isCurrentCircle.value,
(isCurrent) => {
if (isCurrent) {
scrollToCircle();
}
},
{ immediate: true }
);
</script> </script>
<template> <template>
@ -55,10 +65,9 @@ onMounted(() => {
{{ props.circle.title }} {{ props.circle.title }}
</div> </div>
<div v-if="props.isCurrentCircle" class="whitespace-nowrap"> <div v-if="isCurrentCircle" class="whitespace-nowrap">
<LearningPathContinueButton <LearningPathContinueButton
:has-progress="!props.learningPath?.continueData?.has_no_progress" :next-learning-content="props.nextLearningContent"
:url="props.learningPath?.continueData?.url"
></LearningPathContinueButton> ></LearningPathContinueButton>
</div> </div>
</router-link> </router-link>

View File

@ -2,29 +2,35 @@
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue"; import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import LearningPathContinueButton from "@/pages/learningPath/learningPathPage/LearningPathContinueButton.vue"; import LearningPathContinueButton from "@/pages/learningPath/learningPathPage/LearningPathContinueButton.vue";
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils"; import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import type { OldCircle } from "@/services/oldCircle"; import { computed, ref, watch } from "vue";
import type { LearningPath } from "@/services/learningPath"; import type { CircleType, LearningContentWithCompletion } from "@/types";
import { onMounted, ref } from "vue";
const props = defineProps<{ const props = defineProps<{
learningPath: LearningPath | undefined; circle: CircleType;
circle: OldCircle; nextLearningContent: LearningContentWithCompletion | undefined;
isCurrentCircle: boolean;
}>(); }>();
const circleElement = ref<HTMLElement | null>(null); const circleElement = ref<HTMLElement | null>(null);
onMounted(() => { const isCurrentCircle = computed(() => {
if (props.isCurrentCircle) { return props.nextLearningContent?.circle?.id === props.circle.id;
});
watch(
() => isCurrentCircle.value,
(isCurrent) => {
if (isCurrent) {
setTimeout(() => { setTimeout(() => {
circleElement?.value?.scrollIntoView({ circleElement?.value?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
inline: "nearest", inline: "center",
block: "center", block: "nearest",
});
}); });
}, 400);
} }
}); },
{ immediate: true }
);
</script> </script>
<template> <template>
@ -43,8 +49,7 @@ onMounted(() => {
<div v-if="isCurrentCircle" class="whitespace-nowrap pl-4"> <div v-if="isCurrentCircle" class="whitespace-nowrap pl-4">
<LearningPathContinueButton <LearningPathContinueButton
:has-progress="!props.learningPath?.continueData?.has_no_progress" :next-learning-content="props.nextLearningContent"
:url="props.learningPath?.continueData?.url"
></LearningPathContinueButton> ></LearningPathContinueButton>
</div> </div>
</router-link> </router-link>

View File

@ -1,24 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { import type { LearningContentWithCompletion } from "@/types";
hasProgress?: boolean;
url?: string;
}
const props = withDefaults(defineProps<Props>(), { const props = defineProps<{
hasProgress: false, nextLearningContent: LearningContentWithCompletion | undefined;
url: "", }>();
});
</script> </script>
<template> <template>
<router-link <router-link
class="btn-blue mt-2 pl-6" class="btn-blue mt-2 pl-6"
:to="props.url" :to="nextLearningContent?.continueUrl ?? '/hello'"
data-cy="lp-continue-button" data-cy="lp-continue-button"
translate translate
> >
<span> <span>
{{ props.hasProgress ? $t("general.nextStep") : $t("general.start") }} {{
props.nextLearningContent?.firstInCircle
? $t("general.start")
: $t("general.nextStep")
}}
</span> </span>
</router-link> </router-link>
</template> </template>

View File

@ -1,17 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import LearningPathCircleListTile from "@/pages/learningPath/learningPathPage/LearningPathCircleListTile.vue"; import LearningPathCircleListTile from "@/pages/learningPath/learningPathPage/LearningPathCircleListTile.vue";
import type { OldCircle } from "@/services/oldCircle";
import { computed } from "vue"; import { computed } from "vue";
import type { LearningPathType } from "@/types"; import type { LearningContentWithCompletion, LearningPathType } from "@/types";
const props = defineProps<{ const props = defineProps<{
learningPath: LearningPathType | undefined; learningPath: LearningPathType | undefined;
nextLearningContent: LearningContentWithCompletion | undefined;
}>(); }>();
const topics = computed(() => props.learningPath?.topics ?? []); const topics = computed(() => props.learningPath?.topics ?? []);
const isCurrentCircle = (circle: OldCircle) =>
props.learningPath?.nextLearningContent?.parentCircle === circle;
</script> </script>
<template> <template>
@ -22,9 +19,8 @@ const isCurrentCircle = (circle: OldCircle) =>
<LearningPathCircleListTile <LearningPathCircleListTile
v-for="circle in topic.circles" v-for="circle in topic.circles"
:key="circle.id" :key="circle.id"
:learning-path="learningPath"
:circle="circle" :circle="circle"
:is-current-circle="isCurrentCircle(circle)" :next-learning-content="props.nextLearningContent"
></LearningPathCircleListTile> ></LearningPathCircleListTile>
</div> </div>
</template> </template>

View File

@ -80,13 +80,16 @@ const changeViewType = (viewType: ViewType) => {
<!-- Bottom --> <!-- Bottom -->
<div class="bg-white"> <div class="bg-white">
<div v-if="lpQueryResult.learningPath">
<div class="flex flex-col justify-between px-6 sm:flex-row sm:px-12"> <div class="flex flex-col justify-between px-6 sm:flex-row sm:px-12">
<!-- Topics --> <!-- Topics -->
<div <div
v-if="selectedView == 'path'" v-if="selectedView == 'path'"
class="order-2 pb-8 sm:order-1 sm:pb-0 sm:pt-4" class="order-2 pb-8 sm:order-1 sm:pb-0 sm:pt-4"
> >
<LearningPathTopics :topics="learningPath?.topics ?? []"></LearningPathTopics> <LearningPathTopics
:topics="learningPath?.topics ?? []"
></LearningPathTopics>
</div> </div>
<div v-else class="flex-grow"></div> <div v-else class="flex-grow"></div>
@ -103,6 +106,7 @@ const changeViewType = (viewType: ViewType) => {
<LearningPathPathView <LearningPathPathView
:learning-path="learningPath" :learning-path="learningPath"
:use-mobile-layout="useMobileLayout" :use-mobile-layout="useMobileLayout"
:next-learning-content="lpQueryResult.nextLearningContent.value"
></LearningPathPathView> ></LearningPathPathView>
</div> </div>
@ -112,14 +116,16 @@ const changeViewType = (viewType: ViewType) => {
class="flex flex-col pl-6 sm:pl-24" class="flex flex-col pl-6 sm:pl-24"
data-cy="lp-list-view" data-cy="lp-list-view"
> >
<LearningPathListView :learning-path="learningPath"></LearningPathListView> <LearningPathListView
:learning-path="learningPath"
:next-learning-content="lpQueryResult.nextLearningContent.value"
></LearningPathListView>
</div> </div>
<div <div
v-if="useMobileLayout" v-if="useMobileLayout"
class="p-6" class="p-6"
:class="useMobileLayout ? 'bg-gray-200' : ''" :class="useMobileLayout ? 'bg-gray-200' : ''"
> ></div>
<!--<LearningPathAppointmentsMock></LearningPathAppointmentsMock>-->
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import LearningPathCircleColumn from "@/pages/learningPath/learningPathPage/LearningPathCircleColumn.vue"; import LearningPathCircleColumn from "@/pages/learningPath/learningPathPage/LearningPathCircleColumn.vue";
import LearningPathScrollButton from "@/pages/learningPath/learningPathPage/LearningPathScrollButton.vue"; import LearningPathScrollButton from "@/pages/learningPath/learningPathPage/LearningPathScrollButton.vue";
import type { OldCircle } from "@/services/oldCircle";
import { useScroll } from "@vueuse/core"; import { useScroll } from "@vueuse/core";
import { ref } from "vue"; import { ref } from "vue";
import type { LearningPathType } from "@/types"; import type { LearningContentWithCompletion, LearningPathType } from "@/types";
const props = defineProps<{ const props = defineProps<{
learningPath: LearningPathType | undefined; learningPath: LearningPathType | undefined;
nextLearningContent: LearningContentWithCompletion | undefined;
useMobileLayout: boolean; useMobileLayout: boolean;
hideButtons?: boolean; hideButtons?: boolean;
overrideCircleUrlBase?: string; overrideCircleUrlBase?: string;
@ -25,9 +25,6 @@ const isLastCircle = (topicIndex: number, circleIndex: number, numCircles: numbe
topicIndex === (props.learningPath?.topics ?? []).length - 1 && topicIndex === (props.learningPath?.topics ?? []).length - 1 &&
circleIndex === numCircles - 1; circleIndex === numCircles - 1;
const isCurrentCircle = (circle: OldCircle) =>
props.learningPath?.nextLearningContent?.parentCircle === circle;
const scrollRight = () => scrollLearnPathDiagram(scrollIncrement); const scrollRight = () => scrollLearnPathDiagram(scrollIncrement);
const scrollLeft = () => scrollLearnPathDiagram(-scrollIncrement); const scrollLeft = () => scrollLearnPathDiagram(-scrollIncrement);
@ -65,14 +62,12 @@ const scrollLearnPathDiagram = (offset: number) => {
<LearningPathCircleColumn <LearningPathCircleColumn
v-for="(circle, circleIndex) in topic.circles" v-for="(circle, circleIndex) in topic.circles"
:key="circle.id" :key="circle.id"
:learning-path="learningPath"
:circle="circle" :circle="circle"
:topic="topic" :next-learning-content="props.nextLearningContent"
:is-first-circle="isFirstCircle(topicIndex, circleIndex)" :is-first-circle="isFirstCircle(topicIndex, circleIndex)"
:is-last-circle=" :is-last-circle="
isLastCircle(topicIndex, circleIndex, topic.circles.length) isLastCircle(topicIndex, circleIndex, topic.circles.length)
" "
:is-current-circle="isCurrentCircle(circle) && !props.hideButtons"
:override-circle-url=" :override-circle-url="
props.overrideCircleUrlBase props.overrideCircleUrlBase
? `${props.overrideCircleUrlBase}/${circle.slug}` ? `${props.overrideCircleUrlBase}/${circle.slug}`

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Topic } from "@/types"; import type { TopicType } from "@/types";
const props = defineProps<{ const props = defineProps<{
topics: Topic[]; topics: TopicType[];
}>(); }>();
const scrollToTopic = (topic: Topic) => { const scrollToTopic = (topic: TopicType) => {
const id = `topic-${topic.slug}`; const id = `topic-${topic.slug}`;
const el = document.getElementById(id); const el = document.getElementById(id);
el?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); el?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });

View File

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCircleStore } from "@/stores/circle"; import { useCircleStore } from "@/stores/circle";
import type { LearningUnit } from "@/types"; import type { CircleType, LearningUnit } from "@/types";
import * as log from "loglevel"; import * as log from "loglevel";
import { useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession, useLearningPathWithCompletion } from "@/composables";
import { COMPLETION_FAILURE, COMPLETION_SUCCESS } from "@/constants";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue"; import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue"; import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
@ -17,16 +16,17 @@ log.debug("LearningContent.vue setup");
const circleStore = useCircleStore(); const circleStore = useCircleStore();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const courseCompletionData = useLearningPathWithCompletion();
const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" }); const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const previousRoute = getPreviousRoute(); const previousRoute = getPreviousRoute();
const props = defineProps<{ const props = defineProps<{
learningUnit: LearningUnit; learningUnit: LearningUnit;
circle: CircleType;
}>(); }>();
const questions = computed(() => props.learningUnit?.children); const questions = computed(() => props.learningUnit?.performance_criteria ?? []);
const currentQuestion = computed(() => questions.value[questionIndex.value]); const currentQuestion = computed(() => questions.value[questionIndex.value]);
const showPreviousButton = computed(() => questionIndex.value != 0); const showPreviousButton = computed(() => questionIndex.value != 0);
const showNextButton = computed( const showNextButton = computed(
@ -44,7 +44,7 @@ function handleContinue() {
questionIndex.value += 1; questionIndex.value += 1;
} else { } else {
log.debug("continue to next learning content"); log.debug("continue to next learning content");
circleStore.continueFromSelfEvaluation(props.learningUnit); circleStore.continueFromSelfEvaluation(props.learningUnit, props.circle);
} }
} }
@ -56,7 +56,7 @@ function handleBack() {
} }
function handleFinishedLearningContent() { function handleFinishedLearningContent() {
circleStore.closeSelfEvaluation(props.learningUnit, previousRoute); circleStore.closeSelfEvaluation(props.learningUnit, props.circle, previousRoute);
} }
eventBus.on("finishedLearningContent", handleFinishedLearningContent); eventBus.on("finishedLearningContent", handleFinishedLearningContent);
@ -69,7 +69,9 @@ onUnmounted(() => {
<template> <template>
<div v-if="learningUnit"> <div v-if="learningUnit">
<LearningContentContainer <LearningContentContainer
@exit="circleStore.closeSelfEvaluation(props.learningUnit, previousRoute)" @exit="
circleStore.closeSelfEvaluation(props.learningUnit, props.circle, previousRoute)
"
> >
<LearningContentMultiLayout <LearningContentMultiLayout
:current-step="questionIndex" :current-step="questionIndex"
@ -101,7 +103,7 @@ onUnmounted(() => {
'border-2': currentQuestion.completion_status === 'SUCCESS', 'border-2': currentQuestion.completion_status === 'SUCCESS',
}" }"
data-cy="success" data-cy="success"
@click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)" @click="courseCompletionData.markCompletion(currentQuestion, 'SUCCESS')"
> >
<it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy> <it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy>
<span class="text-large font-bold"> <span class="text-large font-bold">
@ -111,12 +113,11 @@ onUnmounted(() => {
<button <button
class="inline-flex flex-1 items-center border p-4 text-left" class="inline-flex flex-1 items-center border p-4 text-left"
:class="{ :class="{
'border-orange-500': 'border-orange-500': currentQuestion.completion_status === 'FAIL',
currentQuestion.completion_status === COMPLETION_FAILURE, 'border-2': currentQuestion.completion_status === 'FAIL',
'border-2': currentQuestion.completion_status === COMPLETION_FAILURE,
}" }"
data-cy="fail" data-cy="fail"
@click="circleStore.markCompletion(currentQuestion, 'FAIL')" @click="courseCompletionData.markCompletion(currentQuestion, 'FAIL')"
> >
<it-icon-smiley-thinking <it-icon-smiley-thinking
class="mr-4 h-16 w-16" class="mr-4 h-16 w-16"

View File

@ -2,9 +2,8 @@
import * as log from "loglevel"; import * as log from "loglevel";
import SelfEvaluation from "@/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue"; import SelfEvaluation from "@/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue";
import { useCircleStore } from "@/stores/circle"; import { computed } from "vue";
import type { LearningUnit } from "@/types"; import { useLearningPathWithCompletion } from "@/composables";
import { onMounted, reactive } from "vue";
log.debug("LearningUnitSelfEvaluationView created"); log.debug("LearningUnitSelfEvaluationView created");
@ -14,32 +13,21 @@ const props = defineProps<{
learningUnitSlug: string; learningUnitSlug: string;
}>(); }>();
const circleStore = useCircleStore(); const courseData = useLearningPathWithCompletion(props.courseSlug);
const learningUnit = computed(() =>
const state: { learningUnit?: LearningUnit } = reactive({}); courseData.findLearningUnit(props.learningUnitSlug, props.circleSlug)
onMounted(async () => {
log.debug(
"LearningUnitSelfEvaluationView mounted",
props.courseSlug,
props.circleSlug,
props.learningUnitSlug
); );
const circle = computed(() => {
try { return courseData.findCircle(props.circleSlug);
state.learningUnit = await circleStore.loadSelfEvaluation(
props.courseSlug,
props.circleSlug,
props.learningUnitSlug
);
} catch (error) {
log.error(error);
}
}); });
</script> </script>
<template> <template>
<SelfEvaluation v-if="state.learningUnit" :learning-unit="state.learningUnit" /> <SelfEvaluation
v-if="learningUnit && circle"
:learning-unit="learningUnit"
:circle="circle"
/>
</template> </template>
<style lang="postcss" scoped></style> <style lang="postcss" scoped></style>

View File

@ -1,177 +0,0 @@
import orderBy from "lodash/orderBy";
import { OldCircle } from "@/services/oldCircle";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import type {
Course,
CourseCompletion,
LearningContentInterface,
LearningPathChild,
Topic,
WagtailLearningPath,
} from "@/types";
// FIXME: remove
export interface ContinueData {
url: string;
has_no_progress: boolean;
}
function getLastCompleted(courseSlug: string, completionData: CourseCompletion[]) {
if (completionData.length === 0) {
return undefined;
}
const courseSessionsStore = useCourseSessionsStore();
const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug);
return orderBy(completionData, ["updated_at"], "desc").find((c: CourseCompletion) => {
return (
c.completion_status === "SUCCESS" &&
c.course_session_id === courseSession?.id &&
c.page_type.startsWith("learnpath.LearningContent")
);
});
}
export class LearningPath implements WagtailLearningPath {
readonly content_type = "learnpath.LearningPath";
public topics: Topic[];
public circles: OldCircle[];
public nextLearningContent?: LearningContentInterface;
public static fromJson(
json: WagtailLearningPath,
completionData: CourseCompletion[],
userId: string | undefined
): LearningPath {
return new LearningPath(
json.id,
json.slug,
json.course.title,
json.translation_key,
json.frontend_url,
json.course,
json.children,
userId,
completionData
);
}
constructor(
public readonly id: string,
public readonly slug: string,
public readonly title: string,
public readonly translation_key: string,
public readonly frontend_url: string,
public readonly course: Course,
public children: LearningPathChild[],
public userId: string | undefined,
completionData?: CourseCompletion[]
) {
// parse children
this.topics = [];
this.circles = [];
let topic: Topic | undefined;
this.children.forEach((page) => {
if (page.content_type === "learnpath.Topic") {
if (topic) {
this.topics.push(topic);
}
topic = Object.assign(page, { circles: [] });
}
if (page.content_type === "learnpath.Circle") {
const circle = OldCircle.fromJson(page, this);
if (completionData && completionData.length > 0) {
circle.parseCompletionData(completionData);
}
if (topic) {
topic.circles.push(circle);
}
circle.previousCircle = this.circles[this.circles.length - 1];
if (circle.previousCircle) {
circle.previousCircle.nextCircle = circle;
}
this.circles.push(circle);
}
});
if (topic) {
this.topics.push(topic);
}
if (completionData) {
this.calcNextLearningContent(completionData);
}
}
public async reloadCompletionData() {
const learningPathStore = useLearningPathStore();
const completionData = await learningPathStore.loadCourseSessionCompletionData(
this.course.slug,
this.userId
);
for (const circle of this.circles) {
circle.parseCompletionData(completionData);
}
}
public calcNextLearningContent(completionData: CourseCompletion[]): void {
this.nextLearningContent = undefined;
const lastCompletedLearningContent = getLastCompleted(
this.course.slug,
completionData
);
if (lastCompletedLearningContent) {
const lastCircle = this.circles.find((circle) => {
return circle.flatLearningContents.find(
(learningContent) =>
learningContent.id === lastCompletedLearningContent.page_id
);
});
if (lastCircle) {
const lastLearningContent = lastCircle.flatLearningContents.find(
(learningContent) =>
learningContent.id === lastCompletedLearningContent.page_id
);
if (lastLearningContent && lastLearningContent.nextLearningContent) {
this.nextLearningContent = lastLearningContent.nextLearningContent;
} else {
if (lastCircle.nextCircle) {
this.nextLearningContent = lastCircle.nextCircle.flatLearningContents[0];
}
}
}
} else {
if (this.circles[0]) {
this.nextLearningContent = this.circles[0].flatLearningContents[0];
}
}
}
public get continueData(): ContinueData {
if (this.nextLearningContent) {
const circle = this.nextLearningContent.parentCircle;
const url =
this.nextLearningContent.parentLearningSequence?.frontend_url ||
circle.frontend_url;
const isFirst =
this.nextLearningContent.translation_key ===
this.circles[0].flatLearningContents[0].translation_key;
return {
url,
has_no_progress: isFirst,
};
}
return {
url: "",
has_no_progress: true,
};
}
}

View File

@ -1,264 +0,0 @@
import type { LearningPath } from "@/services/learningPath";
import type {
CircleChild,
CircleGoal,
CircleJobSituation,
CourseCompletion,
LearningContent,
LearningContentInterface,
LearningSequence,
LearningUnit,
LearningUnitPerformanceCriteria,
WagtailCircle,
} from "@/types";
import groupBy from "lodash/groupBy";
import partition from "lodash/partition";
import values from "lodash/values";
import log from "loglevel";
// FIXME: remove
function isLearningContentType(object: any): object is LearningContent {
return (
object?.content_type === "learnpath.LearningContentAssignment" ||
object?.content_type === "learnpath.LearningContentAttendanceCourse" ||
object?.content_type === "learnpath.LearningContentDocumentList" ||
object?.content_type === "learnpath.LearningContentFeedback" ||
object?.content_type === "learnpath.LearningContentLearningModule" ||
object?.content_type === "learnpath.LearningContentMediaLibrary" ||
object?.content_type === "learnpath.LearningContentPlaceholder" ||
object?.content_type === "learnpath.LearningContentRichText" ||
object?.content_type === "learnpath.LearningContentEdoniqTest" ||
object?.content_type === "learnpath.LearningContentVideo"
);
}
export function parseLearningSequences(
circle: OldCircle,
children: CircleChild[]
): LearningSequence[] {
let learningSequence: LearningSequence | undefined;
let learningUnit: LearningUnit | undefined;
let learningContent: LearningContent | undefined;
let previousLearningContent: LearningContent | undefined;
const result: LearningSequence[] = [];
children.forEach((child) => {
if (child.content_type === "learnpath.LearningSequence") {
if (learningSequence) {
if (learningUnit) {
learningUnit.last = true;
}
}
learningSequence = Object.assign(child, { learningUnits: [] });
result.push(learningSequence);
} else if (child.content_type === "learnpath.LearningUnit") {
if (!learningSequence) {
throw new Error("LearningUnit found before LearningSequence");
}
learningUnit = Object.assign(child, {
learningContents: [],
parentLearningSequence: learningSequence,
parentCircle: circle,
children: child.children.map((c) => {
c.parentLearningUnit = learningUnit;
c.parentLearningSequence = learningSequence;
return c;
}),
});
learningSequence.learningUnits.push(learningUnit);
} else if (isLearningContentType(child)) {
if (!learningUnit) {
throw new Error(`LearningContent found before LearningUnit ${child.slug}`);
}
previousLearningContent = learningContent;
learningContent = Object.assign(child, {
parentCircle: circle,
parentLearningSequence: learningSequence,
parentLearningUnit: learningUnit,
previousLearningContent: previousLearningContent,
});
if (previousLearningContent) {
previousLearningContent.nextLearningContent = learningContent;
}
learningUnit.learningContents.push(child);
} else {
log.error("Unknown CircleChild found...", child);
throw new Error("Unknown CircleChild found...");
}
});
if (learningUnit) {
learningUnit.last = true;
} else {
throw new Error(
"Finished with LearningContent but there is no LearningSequence and LearningUnit"
);
}
// sum minutes
result.forEach((learningSequence) => {
learningSequence.minutes = 0;
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.minutes = 0;
learningUnit.learningContents.forEach((learningContent) => {
learningUnit.minutes += learningContent.minutes;
});
learningSequence.minutes += learningUnit.minutes;
});
});
return result;
}
export class OldCircle implements WagtailCircle {
readonly content_type = "learnpath.Circle";
readonly learningSequences: LearningSequence[];
nextCircle?: OldCircle;
previousCircle?: OldCircle;
constructor(
public readonly id: string,
public readonly slug: string,
public readonly title: string,
public readonly translation_key: string,
public readonly frontend_url: string,
public readonly description: string,
public readonly children: CircleChild[],
public readonly goal_description: string,
public readonly goals: CircleGoal[],
public readonly job_situation_description: string,
public readonly job_situations: CircleJobSituation[],
public readonly parentLearningPath?: LearningPath
) {
this.learningSequences = parseLearningSequences(this, this.children);
}
public static fromJson(
wagtailCircle: WagtailCircle,
learningPath?: LearningPath
): OldCircle {
// TODO add error checking when the data does not conform to the schema
return new OldCircle(
wagtailCircle.id,
wagtailCircle.slug,
wagtailCircle.title,
wagtailCircle.translation_key,
wagtailCircle.frontend_url,
wagtailCircle.description,
wagtailCircle.children,
wagtailCircle.goal_description,
wagtailCircle.goals,
wagtailCircle.job_situation_description,
wagtailCircle.job_situations,
learningPath
);
}
public get flatChildren(): (
| LearningContentInterface
| LearningUnitPerformanceCriteria
)[] {
const result: (LearningContentInterface | LearningUnitPerformanceCriteria)[] = [];
this.learningSequences.forEach((learningSequence) => {
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.children.forEach((performanceCriteria) => {
result.push(performanceCriteria);
});
learningUnit.learningContents.forEach((learningContent) => {
result.push(learningContent);
});
});
});
return result;
}
public get flatLearningContents(): LearningContent[] {
const result: LearningContent[] = [];
this.learningSequences.forEach((learningSequence) => {
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.learningContents.forEach((learningContent) => {
result.push(learningContent);
});
});
});
return result;
}
public get flatLearningUnits(): LearningUnit[] {
const result: LearningUnit[] = [];
this.learningSequences.forEach((learningSequence) => {
learningSequence.learningUnits.forEach((learningUnit) => {
result.push(learningUnit);
});
});
return result;
}
public someFinishedInLearningSequence(translationKey: string): boolean {
if (translationKey) {
return (
this.flatChildren.filter((lc) => {
return (
lc.completion_status === "SUCCESS" &&
lc.parentLearningSequence?.translation_key === translationKey
);
}).length > 0
);
}
return false;
}
public allFinishedInLearningSequence(translationKey: string): boolean {
if (translationKey) {
const [performanceCriteria, learningContents] = partition(
this.flatChildren.filter(
(lc) => lc.parentLearningSequence?.translation_key === translationKey
),
function (child) {
return child.content_type === "competence.PerformanceCriteria";
}
);
const groupedPerformanceCriteria = values(
groupBy(performanceCriteria, (pc) => pc.parentLearningUnit?.id)
);
return (
learningContents.every((lc) => lc.completion_status === "SUCCESS") &&
(groupedPerformanceCriteria.length === 0 ||
groupedPerformanceCriteria.every((group) =>
group.every(
(pc) =>
pc.completion_status === "SUCCESS" || pc.completion_status === "FAIL"
)
))
);
}
return false;
}
public parseCompletionData(completionData: CourseCompletion[]) {
this.flatChildren.forEach((page) => {
const pageIndex = completionData.findIndex((e) => {
return e.page_id === page.id;
});
if (pageIndex >= 0) {
page.completion_status = completionData[pageIndex].completion_status;
} else {
page.completion_status = "UNKNOWN";
}
});
if (this.parentLearningPath) {
this.parentLearningPath.calcNextLearningContent(completionData);
}
}
}

View File

@ -1,28 +1,23 @@
import type { OldCircle } from "@/services/oldCircle";
import { useCompletionStore } from "@/stores/completion";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import type { import type {
CourseCompletionStatus, CircleType,
LearningContentInterface, LearningContent,
LearningContentWithCompletion,
LearningUnit, LearningUnit,
LearningUnitPerformanceCriteria,
PerformanceCriteria,
} from "@/types"; } from "@/types";
import * as log from "loglevel"; import * as log from "loglevel";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { RouteLocationNormalized } from "vue-router"; import type { RouteLocationNormalized } from "vue-router";
export type CircleStoreState = { export type CircleStoreState = {
circle: OldCircle | undefined;
page: "INDEX" | "OVERVIEW"; page: "INDEX" | "OVERVIEW";
}; };
function createLearningUnitHash(learningUnit: LearningUnit | undefined) { function createLearningUnitHash(
const luSlug = learningUnit?.slug; circle: CircleType,
const circleSlug = learningUnit?.parentCircle?.slug; learningUnitSlug: string | undefined
if (luSlug && circleSlug) { ) {
return "#" + luSlug.replace(`${circleSlug}-`, ""); if (learningUnitSlug && circle) {
return "#" + learningUnitSlug.replace(`${circle.slug}-`, "");
} }
return ""; return "";
@ -32,111 +27,30 @@ export const useCircleStore = defineStore({
id: "circle", id: "circle",
state: () => { state: () => {
return { return {
circle: undefined,
page: "INDEX", page: "INDEX",
} as CircleStoreState; } as CircleStoreState;
}, },
getters: {}, getters: {},
actions: { actions: {
async loadCircle( openLearningContent(learningContent: LearningContent) {
courseSlug: string,
circleSlug: string,
userId: string | undefined = undefined
): Promise<OldCircle> {
if (!userId) {
const userStore = useUserStore();
userId = userStore.id;
}
this.circle = undefined;
const learningPathSlug = courseSlug + "-lp";
const learningPathStore = useLearningPathStore();
const learningPath = await learningPathStore.loadLearningPath(
learningPathSlug,
userId
);
if (learningPath) {
this.circle = learningPath.circles.find((circle) => {
return circle.slug.endsWith(circleSlug);
});
}
if (!this.circle) {
throw `No circle found with slug: ${circleSlug}`;
}
return this.circle;
},
async loadLearningContent(
courseSlug: string,
circleSlug: string,
learningContentSlug: string
) {
const circle = await this.loadCircle(courseSlug, circleSlug);
const result = circle.flatLearningContents.find((learningContent) => {
return learningContent.slug.endsWith(learningContentSlug);
});
if (!result) {
throw `No learning content found with slug: ${learningContentSlug}`;
}
return result;
},
async loadSelfEvaluation(
courseSlug: string,
circleSlug: string,
learningUnitSlug: string
) {
const circle = await this.loadCircle(courseSlug, circleSlug);
const learningUnit = circle.flatLearningUnits.find((child) => {
return child.slug.endsWith(learningUnitSlug);
});
if (!learningUnit) {
throw `No self evaluation found with slug: ${learningUnitSlug}`;
}
return learningUnit;
},
async markCompletion(
page:
| LearningContentInterface
| LearningUnitPerformanceCriteria
| PerformanceCriteria
| undefined,
completion_status: CourseCompletionStatus = "SUCCESS"
) {
const completionStore = useCompletionStore();
try {
if (page) {
page.completion_status = completion_status;
const completionData = await completionStore.markPage(page);
if (this.circle) {
this.circle.parseCompletionData(completionData);
}
}
} catch (error) {
log.error(error);
return error;
}
},
openLearningContent(learningContent: LearningContentInterface) {
this.router.push({ this.router.push({
path: learningContent.frontend_url, path: learningContent.frontend_url,
}); });
}, },
closeLearningContent( closeLearningContent(
learningContent: LearningContentInterface, learningContent: LearningContentWithCompletion,
circle: CircleType,
returnRoute?: RouteLocationNormalized returnRoute?: RouteLocationNormalized
) { ) {
if (returnRoute) { if (returnRoute) {
this.router.push(returnRoute); this.router.push(returnRoute);
} else { } else {
this.router.push({ this.router.push({
path: `${this.circle?.frontend_url}`, path: `${circle.frontend_url}`,
hash: createLearningUnitHash(learningContent.parentLearningUnit), hash: createLearningUnitHash(
circle,
learningContent.parentLearningUnit?.slug
),
}); });
} }
}, },
@ -147,35 +61,37 @@ export const useCircleStore = defineStore({
}, },
closeSelfEvaluation( closeSelfEvaluation(
learningUnit: LearningUnit, learningUnit: LearningUnit,
circle: CircleType,
returnRoute?: RouteLocationNormalized returnRoute?: RouteLocationNormalized
) { ) {
if (returnRoute) { if (returnRoute) {
this.router.push(returnRoute); this.router.push(returnRoute);
} else { } else {
this.router.push({ this.router.push({
path: `${this.circle?.frontend_url}`, path: `${circle.frontend_url}`,
hash: createLearningUnitHash(learningUnit), hash: createLearningUnitHash(circle, learningUnit.slug),
}); });
} }
}, },
continueFromLearningContent( continueFromLearningContent(
currentLearningContent: LearningContentInterface, currentLearningContent: LearningContentWithCompletion,
returnRoute?: RouteLocationNormalized circle: CircleType,
returnRoute?: RouteLocationNormalized,
markCompletionFn?: (learningContent: LearningContentWithCompletion) => void
) { ) {
if (currentLearningContent) { if (currentLearningContent) {
if (currentLearningContent.can_user_self_toggle_course_completion) { if (currentLearningContent.can_user_self_toggle_course_completion) {
this.markCompletion(currentLearningContent, "SUCCESS"); if (markCompletionFn) {
} else { markCompletionFn(currentLearningContent);
// reload completion data anyway
currentLearningContent.parentCircle?.parentLearningPath?.reloadCompletionData();
} }
this.closeLearningContent(currentLearningContent, returnRoute); }
this.closeLearningContent(currentLearningContent, circle, returnRoute);
} else { } else {
log.error("currentLearningContent is undefined"); log.error("currentLearningContent is undefined");
} }
}, },
continueFromSelfEvaluation(learningUnit: LearningUnit) { continueFromSelfEvaluation(learningUnit: LearningUnit, circle: CircleType) {
this.closeSelfEvaluation(learningUnit); this.closeSelfEvaluation(learningUnit, circle);
}, },
}, },
}); });

View File

@ -1,122 +0,0 @@
import { itGetCached } from "@/fetchHelpers";
import { LearningPath } from "@/services/learningPath";
import { useCompletionStore } from "@/stores/completion";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type { CourseCompletion } from "@/types";
import eventBus from "@/utils/eventBus";
import cloneDeep from "lodash/cloneDeep";
import log from "loglevel";
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
// FIXME: remove
export type LearningPathStoreState = {
learningPaths: Map<string, LearningPath>;
page: "INDEX" | "OVERVIEW";
};
type LearningPathKey = string;
function getLearningPathKey(
slug: string,
userId: string | number | undefined
): LearningPathKey {
return `${slug}-${userId}`;
}
export const useLearningPathStore = defineStore("learningPath", () => {
const state: LearningPathStoreState = reactive({
learningPaths: new Map<LearningPathKey, LearningPath>(),
page: "INDEX",
});
const learningPathForUser = computed(() => {
return (courseSlug: string, userId: string | number | undefined) => {
if (state.learningPaths.size > 0) {
const learningPathKey = getLearningPathKey(`${courseSlug}-lp`, userId);
return state.learningPaths.get(learningPathKey);
}
return undefined;
};
});
async function loadCourseSessionCompletionData(
courseSlug: string,
userId: string | undefined = undefined
) {
// FIXME: should not be here anymore with VBV-305
const completionStore = useCompletionStore();
let completionData: CourseCompletion[] = [];
if (userId) {
const courseSessionsStore = useCourseSessionsStore();
const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug);
if (courseSession) {
completionData = await completionStore.loadCourseSessionCompletionData(
courseSession.id,
userId
);
return completionData;
}
}
return [];
}
async function loadLearningPath(
slug: string,
userId: string | undefined = undefined,
reload = false,
fail = true
) {
if (!userId) {
const userStore = useUserStore();
userId = userStore.id;
}
const key = getLearningPathKey(slug, userId);
if (state.learningPaths.has(key) && !reload) {
return state.learningPaths.get(key);
}
const learningPathData = await itGetCached(`/api/course/page/${slug}/`);
if (!learningPathData && fail) {
throw `No learning path found with: ${slug}`;
}
const completionData = await loadCourseSessionCompletionData(
learningPathData.course.slug,
userId
);
const learningPath = LearningPath.fromJson(
cloneDeep(learningPathData),
completionData,
userId
);
state.learningPaths.set(key, learningPath);
return learningPath;
}
eventBus.on("switchedCourseSession", (courseSession) => {
log.debug("handle switchedCourseSession", courseSession);
// FIXME: clean up with VBV-305
// Die Completion Daten werden nur für die aktuelle Durchführung geladen.
// Deshalb müssen die hier neu geladen werden...
state.learningPaths.forEach((lp) => {
if (lp.userId) {
lp.reloadCompletionData();
}
});
});
return {
state,
learningPathForUser,
loadCourseSessionCompletionData,
loadLearningPath,
};
});

View File

@ -104,7 +104,16 @@ export type LearningContent =
| LearningContentRichText | LearningContentRichText
| LearningContentVideo; | LearningContentVideo;
export type LearningContentWithCompletion = LearningContent & Completable; export type LearningContentWithCompletion = LearningContent &
Completable & {
continueUrl?: string;
firstInCircle?: boolean;
parentLearningUnit?: {
id: string;
slug: string;
title: string;
};
};
export type LearningContentContentType = LearningContent["content_type"]; export type LearningContentContentType = LearningContent["content_type"];
@ -115,6 +124,7 @@ export type LearningUnit = Omit<
content_type: "learnpath.LearningUnit"; content_type: "learnpath.LearningUnit";
learning_contents: LearningContentWithCompletion[]; learning_contents: LearningContentWithCompletion[];
performance_criteria: LearningUnitPerformanceCriteria[]; performance_criteria: LearningUnitPerformanceCriteria[];
circle?: CircleLight;
}; };
export type LearningSequence = Omit< export type LearningSequence = Omit<

View File

@ -211,24 +211,21 @@ class LearningUnitObjectType(DjangoObjectType):
interfaces = (CoursePageInterface,) interfaces = (CoursePageInterface,)
fields = ["evaluate_url", "title_hidden"] fields = ["evaluate_url", "title_hidden"]
@staticmethod def resolve_evaluate_url(self: LearningUnit, info, **kwargs):
def resolve_evaluate_url(root: LearningUnit, info, **kwargs): return self.get_evaluate_url()
return root.get_evaluate_url()
@staticmethod def resolve_performance_criteria(self: LearningUnit, info, **kwargs):
def resolve_performance_criteria(root: LearningUnit, info, **kwargs): return self.performancecriteria_set.all()
return root.performancecriteria_set.all()
@staticmethod def resolve_learning_contents(self: LearningUnit, info, **kwargs):
def resolve_learning_contents(root: LearningUnit, info, **kwargs):
siblings = None siblings = None
if hasattr(info.context, "circle_descendants"): if hasattr(info.context, "circle_descendants"):
circle_descendants = info.context.circle_descendants circle_descendants = info.context.circle_descendants
index = circle_descendants.index(root) index = circle_descendants.index(self)
siblings = circle_descendants[index + 1 :] siblings = circle_descendants[index + 1 :]
if not siblings: if not siblings:
siblings = root.get_siblings().live().specific() siblings = self.get_siblings().live().specific()
learning_contents = [] learning_contents = []
for sibling in siblings: for sibling in siblings:
@ -286,21 +283,23 @@ class CircleObjectType(DjangoObjectType):
"goals", "goals",
] ]
@staticmethod def resolve_learning_sequences(self: Circle, info, **kwargs):
def resolve_learning_sequences(root: Circle, info, **kwargs):
circle_descendants = None circle_descendants = None
if hasattr(info.context, "learning_path_descendants"): if hasattr(info.context, "learning_path_descendants"):
children = info.context.learning_path_descendants children = info.context.learning_path_descendants
circle_start_index = children.index(root) circle_start_index = children.index(self)
next_children = children[circle_start_index + 1 :]
next_circle_index = find_first_index( next_circle_index = find_first_index(
children[circle_start_index + 1 :], next_children,
pred=lambda child: child.specific_class == Circle, pred=lambda child: child.specific_class == Circle,
) )
circle_descendants = children[circle_start_index + 1 : next_circle_index] circle_descendants = next_children[0:next_circle_index]
if circle_descendants[-1].specific_class == Topic:
circle_descendants = circle_descendants[:-1]
if not circle_descendants: if not circle_descendants:
circle_descendants = list(root.get_descendants().live().specific()) circle_descendants = list(self.get_descendants().live().specific())
# store flattened descendents to improve performance (no need for db queries) # store flattened descendents to improve performance (no need for db queries)
info.context.circle_descendants = list(circle_descendants) info.context.circle_descendants = list(circle_descendants)