Merged in feature/VBV-260-implement-new-learnpath (pull request #28)
Feature/VBV-260 implement new learnpath * Fix linting problems * Fix cypress tests * Change various texts to gray-800 * Rework progress bar to show in progress circles * Scroll to initial circle * Make list view circles clickable * Minor rework actions * Rework * Fix LearningPathScrollButton alignment * Additional fix for LearningPathScrollButton alignment
This commit is contained in:
parent
39bc70eff0
commit
a7dcb86cfe
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<p class="mb-4 font-bold">Nächste Termine</p>
|
||||
<ul>
|
||||
<li class="border-b border-t py-3">
|
||||
<p class="pr-12">24. November 2022, 11 Uhr - Austausch mit Trainer</p>
|
||||
</li>
|
||||
<li class="border-b py-3">
|
||||
<p class="pr-12">4. Dezember 2022, 10.30 Uhr - Vernetzen - Live Session</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="pt-2 underline">Alle Termine anzeigen</p>
|
||||
</template>
|
||||
|
|
@ -51,9 +51,17 @@ const pieData = computed(() => {
|
|||
.reverse() as CircleSector[];
|
||||
});
|
||||
|
||||
const width = 450;
|
||||
const height = 450;
|
||||
const radius = Math.min(width, height) / 2.4;
|
||||
const width = 100;
|
||||
const height = 100;
|
||||
const radius = 50;
|
||||
|
||||
const svgId = computed(() => {
|
||||
// Generate a random id for the svg element like 'circle-visualization-ziurxxmp'
|
||||
return `circle-visualization-${Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, "")
|
||||
.substring(2, 10)}`;
|
||||
});
|
||||
|
||||
function getColor(sector: CircleSector) {
|
||||
let color = colors.gray[300];
|
||||
|
|
@ -67,7 +75,7 @@ function getColor(sector: CircleSector) {
|
|||
}
|
||||
|
||||
function render() {
|
||||
const svg = d3.select(".circle-visualization");
|
||||
const svg = d3.select("#" + svgId.value);
|
||||
// Clean svg before adding new stuff.
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
|
|
@ -124,10 +132,10 @@ function render() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="svg-container h-full content-center">
|
||||
<div class="svg-container content-center">
|
||||
<pre hidden>{{ pieData }}</pre>
|
||||
<pre hidden>{{ render() }}</pre>
|
||||
<svg class="circle-visualization h-full">
|
||||
<svg :id="svgId" class="h-full">
|
||||
<circle :cx="width / 2" :cy="height / 2" :r="radius" :color="colors.gray[300]" />
|
||||
<circle :cx="width / 2" :cy="height / 2" :r="radius / 2.5" color="white" />
|
||||
</svg>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import type { CircleSectorData } from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import LearningPathCircle from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import LearningPathContinueButton from "@/components/learningPath/page/LearningPathContinueButton.vue";
|
||||
import { calculateCircleSectorData } from "@/components/learningPath/page/utils";
|
||||
import type { Circle } from "@/services/circle";
|
||||
import type { LearningPath } from "@/services/learningPath";
|
||||
import type { Topic } from "@/types";
|
||||
|
||||
const props = defineProps<{
|
||||
learningPath: LearningPath | undefined;
|
||||
circle: Circle;
|
||||
topic: Topic;
|
||||
isFirstCircle: boolean;
|
||||
isLastCircle: boolean;
|
||||
isCurrentCircle: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row items-center pb-2">
|
||||
<div class="w-12">
|
||||
<hr v-if="!props.isFirstCircle" class="h-[1.6px] w-full border-0 bg-gray-500" />
|
||||
</div>
|
||||
<LearningPathCircle
|
||||
:sectors="calculateCircleSectorData(circle)"
|
||||
class="h-24 w-24 snap-center"
|
||||
></LearningPathCircle>
|
||||
<div class="w-12">
|
||||
<hr v-if="!props.isLastCircle" class="h-[1.6px] w-full border-0 bg-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="props.isCurrentCircle" class="text-sm text-gray-800">
|
||||
{{ $t("learningPathPage.currentCircle") }}
|
||||
</span>
|
||||
|
||||
<div class="w-36 text-center text-lg font-bold text-blue-900">
|
||||
{{ props.circle.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="props.isCurrentCircle" class="whitespace-nowrap">
|
||||
<LearningPathContinueButton
|
||||
:has-progress="!props.learningPath?.continueData?.has_no_progress"
|
||||
:url="props.learningPath?.continueData?.url"
|
||||
></LearningPathContinueButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
hasProgress: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasProgress: false,
|
||||
url: "",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
class="btn-blue mt-2 pl-6"
|
||||
:to="props.url"
|
||||
data-cy="lp-continue-button"
|
||||
translate
|
||||
>
|
||||
<span>
|
||||
{{ props.hasProgress ? $t("general.nextStep") : $t("general.start") }}
|
||||
</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import type { CircleSectorData } from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import LearningPathCircle from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import LearningPathContinueButton from "@/components/learningPath/page/LearningPathContinueButton.vue";
|
||||
import { calculateCircleSectorData } from "@/components/learningPath/page/utils";
|
||||
import type { Circle } from "@/services/circle";
|
||||
import type { LearningPath } from "@/services/learningPath";
|
||||
import { computed, onMounted } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
learningPath: LearningPath | undefined;
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
const initialCircle = props.learningPath?.nextLearningContent?.parentCircle;
|
||||
if (initialCircle) {
|
||||
scrollToCircle(initialCircle);
|
||||
}
|
||||
});
|
||||
|
||||
const topics = computed(() => props.learningPath?.topics ?? []);
|
||||
|
||||
const shouldContinueHere = (circle: Circle) =>
|
||||
props.learningPath?.nextLearningContent?.parentCircle === circle;
|
||||
|
||||
const scrollToCircle = (circle: Circle) => {
|
||||
const id = `circle-${circle.slug}`;
|
||||
const el = document.getElementById(id);
|
||||
el?.scrollIntoView({ behavior: "smooth", inline: "center", block: "end" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="topic in topics" :key="topic.title">
|
||||
<div class="pb-2 font-bold text-gray-700">
|
||||
{{ topic.title }}
|
||||
</div>
|
||||
<router-link
|
||||
v-for="circle in topic.circles"
|
||||
:id="`circle-${circle.slug}`"
|
||||
:key="circle.id"
|
||||
:to="circle.frontend_url"
|
||||
class="flex flex-row items-center pb-2"
|
||||
>
|
||||
<LearningPathCircle
|
||||
:sectors="calculateCircleSectorData(circle)"
|
||||
class="h-12 w-12"
|
||||
></LearningPathCircle>
|
||||
<div class="pl-3 font-bold text-blue-900">
|
||||
{{ circle.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="shouldContinueHere(circle)" class="whitespace-nowrap pl-4">
|
||||
<LearningPathContinueButton
|
||||
:has-progress="!props.learningPath?.continueData?.has_no_progress"
|
||||
:url="props.learningPath?.continueData?.url"
|
||||
></LearningPathContinueButton>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script setup lang="ts">
|
||||
import type { CircleSectorData } from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import LearningPathCircleColumn from "@/components/learningPath/page/LearningPathCircleColumn.vue";
|
||||
import LearningPathScrollButton from "@/components/learningPath/page/LearningPathScrollButton.vue";
|
||||
import type { Circle } from "@/services/circle";
|
||||
import type { LearningPath } from "@/services/learningPath";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
learningPath: LearningPath | undefined;
|
||||
useMobileLayout: boolean;
|
||||
}>();
|
||||
|
||||
// Scroll bounds
|
||||
const isAtLeftEnd = ref(true);
|
||||
const isAtRightEnd = ref(false);
|
||||
|
||||
// Constants
|
||||
const scrollIncrement = 600;
|
||||
const learnPathDiagramId = "learnpath-diagram";
|
||||
|
||||
onMounted(async () => {
|
||||
watchScrollBounds();
|
||||
|
||||
const initialCircle = props.learningPath?.nextLearningContent?.parentCircle;
|
||||
if (initialCircle) {
|
||||
scrollToCircle(initialCircle);
|
||||
}
|
||||
});
|
||||
|
||||
const isFirstCircle = (topicIndex: number, circleIndex: number) =>
|
||||
topicIndex === 0 && circleIndex === 0;
|
||||
|
||||
const isLastCircle = (topicIndex: number, circleIndex: number, numCircles: number) =>
|
||||
topicIndex === (props.learningPath?.topics ?? []).length - 1 &&
|
||||
circleIndex === numCircles - 1;
|
||||
|
||||
const isCurrentCircle = (circle: Circle) =>
|
||||
props.learningPath?.nextLearningContent?.parentCircle === circle;
|
||||
|
||||
const watchScrollBounds = () => {
|
||||
// We need to monitor the scroll bounds of the diagram to hide the scroll buttons when we're at the end
|
||||
const el = document.getElementById(learnPathDiagramId);
|
||||
el?.addEventListener("scroll", () => recalculateScrollBounds());
|
||||
};
|
||||
|
||||
const recalculateScrollBounds = () => {
|
||||
const el = document.getElementById(learnPathDiagramId);
|
||||
const scrollLeft = el?.scrollLeft ?? 0;
|
||||
const scrollWidth = el?.scrollWidth ?? 0;
|
||||
isAtLeftEnd.value = scrollLeft === 0;
|
||||
isAtRightEnd.value = scrollLeft > scrollWidth - (el?.clientWidth ?? 0) - 1;
|
||||
};
|
||||
|
||||
const scrollRight = () => scrollLearnPathDiagram(scrollIncrement);
|
||||
|
||||
const scrollLeft = () => scrollLearnPathDiagram(-scrollIncrement);
|
||||
|
||||
const scrollLearnPathDiagram = (offset: number) => {
|
||||
const el = document.getElementById(learnPathDiagramId);
|
||||
const newScrollOffset = (el?.scrollLeft ?? 0) + offset;
|
||||
el?.scroll({
|
||||
left: newScrollOffset,
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToCircle = (circle: Circle) => {
|
||||
const id = `circle-${circle.slug}`;
|
||||
const el = document.getElementById(id);
|
||||
el?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-row items-center">
|
||||
<LearningPathScrollButton
|
||||
v-show="!isAtLeftEnd"
|
||||
direction="left"
|
||||
:hidden="props.useMobileLayout"
|
||||
@click="scrollLeft"
|
||||
></LearningPathScrollButton>
|
||||
<div
|
||||
:id="learnPathDiagramId"
|
||||
class="flex h-96 snap-x flex-row overflow-auto py-5 sm:overflow-hidden sm:py-10"
|
||||
>
|
||||
<div
|
||||
v-for="(topic, topicIndex) in props.learningPath?.topics ?? []"
|
||||
:key="topic.title"
|
||||
class="border-l border-gray-500"
|
||||
:class="topicIndex == 0 ? 'ml-6 sm:ml-12' : ''"
|
||||
>
|
||||
<p
|
||||
:id="`topic-${topic.slug}`"
|
||||
class="inline-block h-12 self-start px-4 font-bold text-gray-800"
|
||||
>
|
||||
{{ topic.title }}
|
||||
</p>
|
||||
<div class="flex flex-row pt-6">
|
||||
<router-link
|
||||
v-for="(circle, circleIndex) in topic.circles"
|
||||
:id="`circle-${circle.slug}`"
|
||||
:key="circle.id"
|
||||
:to="circle.frontend_url"
|
||||
:data-cy="`circle-${circle.title}`"
|
||||
class="flex flex-col items-center pb-6"
|
||||
>
|
||||
<LearningPathCircleColumn
|
||||
:learning-path="learningPath"
|
||||
:circle="circle"
|
||||
:topic="topic"
|
||||
:is-first-circle="isFirstCircle(topicIndex, circleIndex)"
|
||||
:is-last-circle="
|
||||
isLastCircle(topicIndex, circleIndex, topic.circles.length)
|
||||
"
|
||||
:is-current-circle="isCurrentCircle(circle)"
|
||||
></LearningPathCircleColumn>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LearningPathScrollButton
|
||||
v-show="!isAtRightEnd"
|
||||
direction="right"
|
||||
:hidden="props.useMobileLayout"
|
||||
@click="scrollRight"
|
||||
></LearningPathScrollButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ allCount: number; inProgressCount: number }>();
|
||||
|
||||
const progress = computed(() => {
|
||||
if (props.allCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (props.inProgressCount / props.allCount) * 100;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<span class="relative inline-block h-2 w-full bg-white">
|
||||
<div
|
||||
v-if="progress > 0"
|
||||
class="absolute h-full bg-green-500"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</span>
|
||||
<p>
|
||||
{{
|
||||
$t("learningPathPage.progressText", {
|
||||
inProgressCount: props.inProgressCount,
|
||||
allCount: props.allCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
export type Direction = "right" | "left";
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const props = defineProps<{
|
||||
direction: Direction;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition
|
||||
leave-active-class="transition ease-out duration-600"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition ease-in duration-600"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
>
|
||||
<button
|
||||
class="absolute h-full w-32 from-white"
|
||||
:class="
|
||||
props.direction === 'right'
|
||||
? 'right-0 bg-gradient-to-l'
|
||||
: 'left-0 bg-gradient-to-r'
|
||||
"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col"
|
||||
:class="props.direction === 'right' ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<component
|
||||
:is="`it-icon-arrow-${props.direction}`"
|
||||
class="mb-16 h-20 w-20 text-gray-600"
|
||||
></component>
|
||||
</div>
|
||||
</button>
|
||||
</transition>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { Topic } from "@/types";
|
||||
|
||||
const props = defineProps<{
|
||||
topics: Topic[];
|
||||
}>();
|
||||
|
||||
const scrollToTopic = (topic: Topic) => {
|
||||
const id = `topic-${topic.slug}`;
|
||||
const el = document.getElementById(id);
|
||||
el?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-sm text-gray-800">
|
||||
<span>{{ $t("learningPathPage.topics") }}</span>
|
||||
<button
|
||||
v-for="topic in props.topics"
|
||||
:key="topic.slug"
|
||||
class="pl-2 underline sm:pl-4"
|
||||
@click="scrollToTopic(topic)"
|
||||
>
|
||||
{{ topic.title }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import ItToggleSwitch from "@/components/ui/ItToggleSwitch.vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export type ViewType = "path" | "list";
|
||||
|
||||
const props = defineProps<{
|
||||
initialView: ViewType;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "selectView", value: ViewType): void;
|
||||
}>();
|
||||
|
||||
const selectedView = ref<ViewType>(props.initialView);
|
||||
|
||||
const activeStyle = "text-black";
|
||||
const inactiveStyle = "text-gray-700";
|
||||
|
||||
const pathViewLabelStyle = computed(() =>
|
||||
selectedView.value == "path" ? activeStyle : inactiveStyle
|
||||
);
|
||||
|
||||
const listViewLabelStyle = computed(() =>
|
||||
selectedView.value == "list" ? activeStyle : inactiveStyle
|
||||
);
|
||||
|
||||
const onToggle = () => {
|
||||
if (selectedView.value == "path") {
|
||||
selectedView.value = "list";
|
||||
} else {
|
||||
selectedView.value = "path";
|
||||
}
|
||||
emit("selectView", selectedView.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row items-center space-x-2 text-sm">
|
||||
<span :class="pathViewLabelStyle">
|
||||
{{ $t("learningPathPage.pathView") }}
|
||||
</span>
|
||||
<ItToggleSwitch
|
||||
data-cy="view-switch"
|
||||
:initially-switched-left="selectedView == 'list'"
|
||||
@click="onToggle"
|
||||
></ItToggleSwitch>
|
||||
<span :class="listViewLabelStyle">
|
||||
{{ $t("learningPathPage.listView") }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type {
|
||||
CircleSectorData,
|
||||
CircleSectorProgress,
|
||||
} from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import type { Circle } from "@/services/circle";
|
||||
|
||||
export function calculateCircleSectorData(circle: Circle): CircleSectorData[] {
|
||||
const sectors = circle.learningSequences.map((ls) => {
|
||||
let progress: CircleSectorProgress = "none";
|
||||
if (circle.allFinishedInLearningSequence(ls.translation_key)) {
|
||||
progress = "finished";
|
||||
} else if (circle.someFinishedInLearningSequence(ls.translation_key)) {
|
||||
progress = "in_progress";
|
||||
}
|
||||
return {
|
||||
progress: progress,
|
||||
};
|
||||
});
|
||||
return sectors;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { Switch } from "@headlessui/vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface Props {
|
||||
initiallySwitchedLeft: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initiallySwitchedLeft: false,
|
||||
});
|
||||
|
||||
const isSwitchedLeft = ref(props.initiallySwitchedLeft);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch
|
||||
v-model="isSwitchedLeft"
|
||||
class="relative inline-flex h-5 w-11 min-w-[44px] items-center rounded-full bg-gray-200"
|
||||
>
|
||||
<span
|
||||
:class="isSwitchedLeft ? 'translate-x-6' : 'translate-x-0'"
|
||||
class="inline-block h-5 w-5 transform rounded-full bg-black transition"
|
||||
/>
|
||||
</Switch>
|
||||
</template>
|
||||
|
|
@ -126,9 +126,14 @@
|
|||
"completeAndContinue": "Als erledigt markieren"
|
||||
},
|
||||
"learningPathPage": {
|
||||
"currentCircle": "Aktueller Circle",
|
||||
"listView": "Listenansicht",
|
||||
"nextStep": "Nächster Schritt",
|
||||
"pathView": "Pfadansicht",
|
||||
"progressText": "Du hast { inProgressCount } von { allCount } Circles bearbeitet",
|
||||
"showListView": "Listenansicht anzeigen",
|
||||
"welcomeBack": "Willkommen zurück, {name}"
|
||||
"topics": "Themen:",
|
||||
"welcomeBack": "Hallo { name }! Willkommen zurück in deinem Lehrgang:"
|
||||
},
|
||||
"mainNavigation": {
|
||||
"logout": "Abmelden",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import IconLogout from "@/components/icons/IconLogout.vue";
|
||||
import IconSettings from "@/components/icons/IconSettings.vue";
|
||||
import LearningPathCircle from "@/components/learningPath/LearningPathCircle.vue";
|
||||
import LearningPathCircle from "@/components/learningPath/page/LearningPathCircle.vue";
|
||||
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
|
||||
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
|
||||
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
|
||||
|
|
@ -9,6 +9,7 @@ import ItDropdown from "@/components/ui/ItDropdown.vue";
|
|||
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
|
||||
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
|
||||
import ItTextarea from "@/components/ui/ItTextarea.vue";
|
||||
import ItToggleSwitch from "@/components/ui/ItToggleSwitch.vue";
|
||||
import RatingScale from "@/components/ui/RatingScale.vue";
|
||||
import VerticalBarChart from "@/components/ui/VerticalBarChart.vue";
|
||||
import logger from "loglevel";
|
||||
|
|
@ -517,6 +518,10 @@ function log(data: any) {
|
|||
},
|
||||
]"
|
||||
></LearningPathCircle>
|
||||
|
||||
<h2 class="mt-8 mb-8">ItToggleSwitch</h2>
|
||||
|
||||
<ItToggleSwitch></ItToggleSwitch>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import * as log from "loglevel";
|
||||
|
||||
import LearningPathAppointmentsMock from "@/components/learningPath/page/LearningPathAppointmentsMock.vue";
|
||||
import LearningPathListView from "@/components/learningPath/page/LearningPathListView.vue";
|
||||
import LearningPathPathView from "@/components/learningPath/page/LearningPathPathView.vue";
|
||||
import CircleProgress from "@/components/learningPath/page/LearningPathProgress.vue";
|
||||
import LearningPathTopics from "@/components/learningPath/page/LearningPathTopics.vue";
|
||||
import type { ViewType } from "@/components/learningPath/page/LearningPathViewSwitch.vue";
|
||||
import LearningPathViewSwitch from "@/components/learningPath/page/LearningPathViewSwitch.vue";
|
||||
import { useLearningPathStore } from "@/stores/learningPath";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { computed, onMounted } from "vue";
|
||||
|
||||
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
|
||||
import LearningPathViewVertical from "@/components/learningPath/LearningPathViewVertical.vue";
|
||||
import type { LearningPath } from "@/services/learningPath";
|
||||
|
||||
log.debug("LearningPathView created");
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
courseSlug: string;
|
||||
|
|
@ -18,108 +19,136 @@ const props = defineProps<{
|
|||
const learningPathStore = useLearningPathStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const learningPath = computed(() => {
|
||||
if (userStore.loggedIn && learningPathStore.learningPaths.size > 0) {
|
||||
const learningPathKey = `${props.courseSlug}-lp-${userStore.id}`;
|
||||
return learningPathStore.learningPaths.get(learningPathKey);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
// Layout state
|
||||
const useMobileLayout = ref(false);
|
||||
const selectedView = ref<ViewType>(
|
||||
(window.localStorage.getItem("learningPathView") as ViewType) || "path"
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
log.debug("LearningPathView mounted");
|
||||
log.debug("LearningPathPage mounted");
|
||||
|
||||
try {
|
||||
await learningPathStore.loadLearningPath(props.courseSlug + "-lp");
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
// Detect if we're on a mobile layout
|
||||
watchLayoutChanges();
|
||||
});
|
||||
|
||||
const createContinueUrl = (learningPath: LearningPath): [string, boolean] => {
|
||||
if (learningPath?.nextLearningContent) {
|
||||
const circle = learningPath.nextLearningContent.parentCircle;
|
||||
const url =
|
||||
learningPath.nextLearningContent.parentLearningSequence?.frontend_url ||
|
||||
circle.frontend_url;
|
||||
const isFirst =
|
||||
learningPath.nextLearningContent.translation_key ===
|
||||
learningPath.circles[0].flatLearningContents[0].translation_key;
|
||||
return [url, isFirst];
|
||||
const learningPath = computed(() => {
|
||||
if (userStore.loggedIn && learningPathStore.learningPaths.size > 0) {
|
||||
const learningPathKey = `${props.courseSlug}-lp-${userStore.id}`;
|
||||
return learningPathStore.learningPaths.get(learningPathKey);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return ["", false];
|
||||
const circlesCount = computed(() => {
|
||||
if (learningPath.value) {
|
||||
return learningPath.value.circles.length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const inProgressCirclesCount = computed(() => {
|
||||
if (learningPath.value) {
|
||||
return learningPath.value.circles.filter(
|
||||
(circle) =>
|
||||
circle.learningSequences.filter((ls) =>
|
||||
circle.someFinishedInLearningSequence(ls.translation_key)
|
||||
).length
|
||||
).length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const watchLayoutChanges = () => {
|
||||
const mobileLayoutQuery = window.matchMedia("(min-width: 640px)");
|
||||
useMobileLayout.value = !mobileLayoutQuery.matches;
|
||||
mobileLayoutQuery.onchange = (event) => {
|
||||
useMobileLayout.value = !event.matches;
|
||||
};
|
||||
};
|
||||
|
||||
const changeViewType = (viewType: ViewType) => {
|
||||
selectedView.value = viewType;
|
||||
window.localStorage.setItem("learningPathView", viewType);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="learningPath" class="bg-gray-200">
|
||||
<Teleport to="body">
|
||||
<LearningPathViewVertical
|
||||
:show="learningPathStore.page === 'OVERVIEW'"
|
||||
:learning-path="learningPath"
|
||||
@closemodal="learningPathStore.page = 'INDEX'"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="learningpath flex flex-col">
|
||||
<div class="flex h-max flex-col">
|
||||
<div class="flex flex-col bg-white lg:py-8">
|
||||
<div class="flex justify-end lg:p-4">
|
||||
<button
|
||||
class="btn-text inline-flex items-center px-3 lg:py-2"
|
||||
data-cy="show-list-view"
|
||||
@click="learningPathStore.page = 'OVERVIEW'"
|
||||
>
|
||||
<it-icon-list />
|
||||
{{ $t("learningPathPage.showListView") }}
|
||||
</button>
|
||||
</div>
|
||||
<LearningPathDiagram
|
||||
class="mx-auto max-h-[90px] w-full max-w-[1920px] px-4 lg:max-h-[380px]"
|
||||
diagram-type="horizontal"
|
||||
:learning-path="learningPath"
|
||||
></LearningPathDiagram>
|
||||
<div class="flex flex-col">
|
||||
<!-- Top -->
|
||||
<div class="flex flex-row justify-between space-x-8 bg-gray-200 p-6 sm:p-12">
|
||||
<!-- Left -->
|
||||
<div class="flex flex-col justify-between">
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ $t("learningPathPage.welcomeBack", { name: userStore.first_name }) }}
|
||||
</p>
|
||||
<h2 data-cy="learning-path-title">
|
||||
{{ learningPath?.title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="container-large pt-0 lg:pt-4">
|
||||
<h1 data-cy="learning-path-title" class="mt-6 mb-6 lg:mt-12">
|
||||
{{ learningPath.title }}
|
||||
</h1>
|
||||
<div
|
||||
class="flex flex-col justify-start divide-y divide-gray-500 bg-white p-4 lg:mb-16 lg:flex-row lg:divide-y-0 lg:divide-x"
|
||||
>
|
||||
<div class="flex-auto p-2 lg:p-8">
|
||||
<h2>
|
||||
{{ $t("learningPathPage.welcomeBack", { name: userStore.first_name }) }}
|
||||
</h2>
|
||||
<p class="mt-4 text-xl"></p>
|
||||
</div>
|
||||
<div v-if="learningPath.nextLearningContent" class="flex-2 p-4 lg:p-8">
|
||||
{{ $t("learningPathPage.nextStep") }}
|
||||
<h3>
|
||||
{{ learningPath.nextLearningContent.parentCircle.title }}:
|
||||
{{ learningPath.nextLearningContent.parentLearningSequence?.title }}
|
||||
</h3>
|
||||
<router-link
|
||||
class="btn-blue mt-4"
|
||||
:to="createContinueUrl(learningPath)[0]"
|
||||
data-cy="lp-continue-button"
|
||||
translate
|
||||
>
|
||||
<span v-if="createContinueUrl(learningPath)[1]">
|
||||
{{ $t("general.start") }}
|
||||
</span>
|
||||
<span v-else>{{ $t("general.nextStep") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<CircleProgress
|
||||
:all-count="circlesCount"
|
||||
:in-progress-count="inProgressCirclesCount"
|
||||
></CircleProgress>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div v-if="!useMobileLayout" class="max-w-md">
|
||||
<LearningPathAppointmentsMock></LearningPathAppointmentsMock>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="bg-white">
|
||||
<div class="flex flex-col justify-between px-6 sm:flex-row sm:px-12">
|
||||
<!-- Topics -->
|
||||
<div
|
||||
v-if="selectedView == 'path'"
|
||||
class="order-2 pb-8 sm:order-1 sm:pb-0 sm:pt-4"
|
||||
>
|
||||
<LearningPathTopics :topics="learningPath?.topics ?? []"></LearningPathTopics>
|
||||
</div>
|
||||
<div class="topic"></div>
|
||||
<div v-else class="flex-grow"></div>
|
||||
|
||||
<!-- View switch -->
|
||||
<LearningPathViewSwitch
|
||||
class="order-1 py-8 sm:order-2 sm:py-0 sm:pt-4"
|
||||
:initial-view="selectedView"
|
||||
@select-view="changeViewType($event)"
|
||||
></LearningPathViewSwitch>
|
||||
</div>
|
||||
|
||||
<!-- Path view -->
|
||||
<div v-if="selectedView == 'path'" class="flex flex-col" data-cy="lp-path-view">
|
||||
<LearningPathPathView
|
||||
:learning-path="learningPath"
|
||||
:use-mobile-layout="useMobileLayout"
|
||||
></LearningPathPathView>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div
|
||||
v-if="selectedView == 'list'"
|
||||
class="flex flex-col pl-6 sm:pl-24"
|
||||
data-cy="lp-list-view"
|
||||
>
|
||||
<LearningPathListView :learning-path="learningPath"></LearningPathListView>
|
||||
</div>
|
||||
<div
|
||||
v-if="useMobileLayout"
|
||||
class="p-6"
|
||||
:class="useMobileLayout ? 'bg-gray-200' : ''"
|
||||
>
|
||||
<LearningPathAppointmentsMock></LearningPathAppointmentsMock>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -222,6 +222,12 @@ export class Circle implements WagtailCircle {
|
|||
return false;
|
||||
}
|
||||
|
||||
public isComplete(): boolean {
|
||||
return this.learningSequences.every((ls) =>
|
||||
this.allFinishedInLearningSequence(ls.translation_key)
|
||||
);
|
||||
}
|
||||
|
||||
public parseCompletionData(completionData: CourseCompletion[]) {
|
||||
this.flatChildren.forEach((page) => {
|
||||
const pageIndex = completionData.findIndex((e) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import type {
|
|||
WagtailLearningPath,
|
||||
} from "@/types";
|
||||
|
||||
export interface ContinueData {
|
||||
url: string;
|
||||
has_no_progress: boolean;
|
||||
}
|
||||
|
||||
function getLastCompleted(courseId: number, completionData: CourseCompletion[]) {
|
||||
return orderBy(completionData, ["updated_at"], "desc").find((c: CourseCompletion) => {
|
||||
return (
|
||||
|
|
@ -125,4 +130,25 @@ export class LearningPath implements WagtailLearningPath {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {login} from "./helpers";
|
||||
import { login } from "./helpers";
|
||||
|
||||
describe("learningPath page", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -19,7 +19,7 @@ describe("learningPath page", () => {
|
|||
login("admin", "test");
|
||||
cy.visit("/course/versicherungsvermittler-in-alt/learn");
|
||||
|
||||
cy.get('[data-cy="circle-analyse"]').click({force: true});
|
||||
cy.get('[data-cy="circle-Analyse"]').click({ force: true });
|
||||
|
||||
cy.url().should(
|
||||
"include",
|
||||
|
|
@ -28,20 +28,15 @@ describe("learningPath page", () => {
|
|||
cy.get('[data-cy="circle-title"]').should("contain", "Analyse");
|
||||
});
|
||||
|
||||
it("open listView and click on circle will open circle", () => {
|
||||
it("switch between list and path view", () => {
|
||||
login("admin", "test");
|
||||
cy.visit("/course/versicherungsvermittler-in-alt/learn");
|
||||
|
||||
cy.get('[data-cy="show-list-view"]').click();
|
||||
cy.get('[data-cy="full-screen-modal"]').should("be.visible");
|
||||
|
||||
cy.get('[data-cy="circle-analyse-vertical"]').click({force: true});
|
||||
|
||||
cy.url().should(
|
||||
"include",
|
||||
"/course/versicherungsvermittler-in-alt/learn/analyse"
|
||||
);
|
||||
cy.get('[data-cy="circle-title"]').should("contain", "Analyse");
|
||||
cy.get('[data-cy="lp-path-view"]').should("be.visible");
|
||||
cy.get('[data-cy="view-switch"]').click();
|
||||
cy.get('[data-cy="lp-list-view"]').should("be.visible");
|
||||
cy.get('[data-cy="view-switch"]').click();
|
||||
cy.get('[data-cy="lp-path-view"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("weiter gehts button will open next circle", () => {
|
||||
|
|
@ -49,21 +44,25 @@ describe("learningPath page", () => {
|
|||
cy.visit("/course/versicherungsvermittler-in-alt/learn");
|
||||
|
||||
// first click will open first circle
|
||||
cy.get('[data-cy="lp-continue-button"]').should("contain", "Los geht's");
|
||||
cy.get('[data-cy="lp-continue-button"]').click();
|
||||
cy.get('[data-cy="lp-continue-button"]')
|
||||
.filter(":visible")
|
||||
.should("contain", "Los geht's")
|
||||
.click();
|
||||
cy.get('[data-cy="circle-title"]').should("contain", "Basis");
|
||||
cy.get('[data-cy="back-to-learning-path-button"]').click();
|
||||
|
||||
// mark a learning content in second circle
|
||||
cy.get('[data-cy="circle-analyse"]').click({force: true});
|
||||
cy.get('[data-cy="circle-Analyse"]').click({ force: true });
|
||||
cy.get(
|
||||
'[data-cy="versicherungsvermittler-in-alt-lp-circle-analyse-lc-fachcheck-fahrzeug-checkbox"] > .cy-checkbox'
|
||||
).click();
|
||||
cy.get('[data-cy="back-to-learning-path-button"]').click();
|
||||
|
||||
// click on continue should go to unit-test-circle
|
||||
cy.get('[data-cy="lp-continue-button"]').should("contain", "Weiter geht's");
|
||||
cy.get('[data-cy="lp-continue-button"]').click();
|
||||
cy.get('[data-cy="lp-continue-button"]')
|
||||
.filter(":visible")
|
||||
.should("contain", "Weiter geht's")
|
||||
.click();
|
||||
cy.get('[data-cy="circle-title"]').should("contain", "Analyse");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue